《MMO 服务器开发》7.1 会话表与连接管理

一个账号、一条连接、一份会话,三者一旦不同步就会出顶号误删、幽灵在线这类经典事故。本节给 aegis 写一张连接与账号的双向索引表:单锁版本、64 分片版本,以及顶号时「先摘旧、后关旧」的原子替换顺序,全部用 go test -race 真跑验证,并给出本机并发的实测数字。

7.1 会话表与连接管理

一条 TCP 连接只告诉你「有个 socket 连着」,它不知道对面是谁。对 MMO 来说,真正有用的问题是:账号 alice 现在连在哪条连接上? 反过来也要能答:这条连接属于哪个账号? 这两个方向都必须能 O(1) 回答,而且答案必须永远一致——一旦两个方向对不上,就会出现「玩家显示在线却收不到任何包」「顶号把新会话一起踢了」这类很难复现的事故。

会话表就是维护这两张索引的那块内存。它看起来只是两个 map,但所有麻烦都在并发上:登录、顶号、心跳超时清理、跨网关迁移会同时改这两张表,谁先谁后、锁怎么加,决定了系统在压力下是稳定还是会丢会话。

本节把 aegis 的 internal/session/ 从零写出来:先写单锁的双向表,再写 64 分片的版本,最后把顶号时最容易写错的那一步——旧连接断开的回调绝不能删掉新会话——用测试钉死。所有代码都真跑,含 -race。

本章与站内既有内容的分工

既有文章它负责什么本节只做什么
游戏在线状态目录服务架构设计跨服状态目录、在线广播与一致性取舍不聊跨服,只把单机内存里那张表写对
游戏服务器会话与重连令牌设计令牌形态、有效期与安全边界不设计令牌,只实现「一个账号一条活连接」
游戏服务器会话粘性架构设计负载均衡层怎么把同一账号粘到同一后端不聊 LB,只聊后端拿到的连接怎么登记

一句话:别人讲「会话该长什么样、放哪、怎么粘」,本节讲「拿到连接之后,这张表在并发下怎么不写错」。

为什么是一张双向表

单一方向的索引不够用。只存 account -> conn,当一条连接断开时你只知道「某条 conn 没了」,却不知道它对应哪个账号,于是没法精确地清理;只存 conn -> account,玩家一发消息你就得全表扫描找他的连接。两个方向都要,而且必须由同一把锁保护:如果两张表各用一把锁,中间那一瞬间就会出现「conn 已插入、account 还没指向它」的半更新状态,另一个 goroutine 读到这个状态就会做出错误决策。

结构因此很直白:byConn map[ConnID]*Conn 管连接侧,byAcc map[string]ConnID 管账号侧,一个 sync.Mutex 同时护住两者。下面这段是 L1,go test 可以直接跑:

// file: internal/session/table.go
// tier: L1
// verify: go test ./internal/session/

package session

import (
	"errors"
	"sync"
)

// ConnID identifies one logical client connection.
type ConnID uint64

var (
	ErrUnknownConn = errors.New("session: unknown conn")
	ErrConnClosed  = errors.New("session: conn closed")
)

// Conn is the session table's view of a client connection. The table never
// touches a socket: it tracks identity and forwards sends.
type Conn struct {
	ID      ConnID
	Account string

	mu      sync.Mutex
	closed  bool
	send    func(cmd uint16, payload []byte) error
	onClose func(*Conn)
}

// NewConn wraps a transport-level send function. send may be nil for tests.
func NewConn(send func(cmd uint16, payload []byte) error) *Conn {
	return &Conn{send: send}
}

// Send forwards one command to the client unless the conn is already closed.
func (c *Conn) Send(cmd uint16, payload []byte) error {
	c.mu.Lock()
	closed := c.closed
	c.mu.Unlock()
	if closed {
		return ErrConnClosed
	}
	if c.send == nil {
		return nil
	}
	return c.send(cmd, payload)
}

// Close is idempotent and runs onClose exactly once. The old-connection
// disconnect path is expected to call Table.Remove here.
func (c *Conn) Close() error {
	c.mu.Lock()
	if c.closed {
		c.mu.Unlock()
		return nil
	}
	c.closed = true
	cb := c.onClose
	c.mu.Unlock()
	if cb != nil {
		cb(c)
	}
	return nil
}

// Closed reports whether Close has run.
func (c *Conn) Closed() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.closed
}

// Table is a bidirectional index: connID -> conn and account -> connID. One
// mutex guards both maps, so a lookup and a rebind can never observe a
// half-updated pair.
type Table struct {
	mu     sync.Mutex
	byConn map[ConnID]*Conn
	byAcc  map[string]ConnID
	nextID ConnID
}

// NewTable returns an empty session table.
func NewTable() *Table {
	return &Table{
		byConn: make(map[ConnID]*Conn),
		byAcc:  make(map[string]ConnID),
	}
}

// Bind registers c as the live connection for account and returns the conn it
// evicted, if any. The evicted conn is removed from the table *inside* the
// lock but is NOT closed here: closing it would re-enter Remove from its
// disconnect handler while we still hold the lock.
func (t *Table) Bind(account string, c *Conn) (evicted *Conn) {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.nextID++
	c.ID = t.nextID
	c.Account = account
	if oldID, ok := t.byAcc[account]; ok {
		evicted = t.byConn[oldID]
		delete(t.byConn, oldID)
	}
	t.byConn[c.ID] = c
	t.byAcc[account] = c.ID
	return evicted
}

// Remove detaches connID. It is safe to call from an old connection's
// disconnect path even after the account has been taken over: the account
// index is cleared only when it still points at this conn.
func (t *Table) Remove(connID ConnID) {
	t.mu.Lock()
	defer t.mu.Unlock()
	c, ok := t.byConn[connID]
	if !ok {
		return
	}
	delete(t.byConn, connID)
	if id, ok := t.byAcc[c.Account]; ok && id == connID {
		delete(t.byAcc, c.Account)
	}
}

// Lookup returns the live conn for account.
func (t *Table) Lookup(account string) (*Conn, bool) {
	t.mu.Lock()
	defer t.mu.Unlock()
	id, ok := t.byAcc[account]
	if !ok {
		return nil, false
	}
	c, ok := t.byConn[id]
	return c, ok
}

// Get returns the conn for connID.
func (t *Table) Get(connID ConnID) (*Conn, bool) {
	t.mu.Lock()
	defer t.mu.Unlock()
	c, ok := t.byConn[connID]
	return c, ok
}

// Len reports the number of live connections.
func (t *Table) Len() int {
	t.mu.Lock()
	defer t.mu.Unlock()
	return len(t.byConn)
}

// Snapshot copies all live conns so a fanout can send without holding the lock.
func (t *Table) Snapshot() []*Conn {
	t.mu.Lock()
	defer t.mu.Unlock()
	out := make([]*Conn, 0, len(t.byConn))
	for _, c := range t.byConn {
		out = append(out, c)
	}
	return out
}

Snapshot 单独存在是有原因的:广播(第 8 章)要遍历所有连接发消息,如果边发边持锁,一条慢 socket 就能卡住整张表。正确做法是持锁拷一份 []*Conn 出来,放锁后再逐个发。

并发安全:单锁还是分片锁

单锁表在登录高峰会变成瓶颈:所有新连接都往同一把锁上撞。解法是把表按账号切成 N 个分片,每个分片自带锁,登录只锁一个分片。这里有个不能省的约束——同一账号的两张索引必须在同一个分片里,否则顶号的「摘旧 + 插新」就不是原子的。于是分片按账号哈希来分,并且把分片号塞进 ConnID 的高 6 位,这样拿到 ConnID 反查时不用第二张映射表就能定位分片。下面这段是 L1:

// snippet: internal/session/sharded.go(节选:分片绑定与摘除)

const (
	shardBits = 6
	numShards = 1 << shardBits
	shardMask = ConnID(numShards - 1)
	seqBits   = 56
)

type ShardedTable struct {
	shards [numShards]shard
}

type shard struct {
	mu     sync.Mutex
	byConn map[ConnID]*Conn
	byAcc  map[string]ConnID
	seq    ConnID
}

func shardIndex(account string) ConnID {
	h := fnv.New32a()
	_, _ = h.Write([]byte(account))
	return ConnID(h.Sum32()) & shardMask
}

// Bind behaves exactly like Table.Bind, but only locks one shard.
func (t *ShardedTable) Bind(account string, c *Conn) (evicted *Conn) {
	idx := shardIndex(account)
	s := &t.shards[idx]
	s.mu.Lock()
	defer s.mu.Unlock()
	s.seq++
	c.ID = idx<<seqBits | s.seq // 高 6 位 = 分片号
	c.Account = account
	if oldID, ok := s.byAcc[account]; ok {
		evicted = s.byConn[oldID]
		delete(s.byConn, oldID)
	}
	s.byConn[c.ID] = c
	s.byAcc[account] = c.ID
	return evicted
}

// Remove detaches connID from its shard; the shard index rides in the ID.
func (t *ShardedTable) Remove(connID ConnID) {
	idx := (connID >> seqBits) & shardMask
	s := &t.shards[idx]
	s.mu.Lock()
	defer s.mu.Unlock()
	c, ok := s.byConn[connID]
	if !ok {
		return
	}
	delete(s.byConn, connID)
	if id, ok := s.byAcc[c.Account]; ok && id == connID {
		delete(s.byAcc, c.Account)
	}
}

分片不是免费的:Len() 要遍历所有分片,快照也更麻烦,而且分片数一旦定死就不太好改。值不值得?看实测。本机 10 核、1000 个账号、并行 Bind(go test -bench . -benchmem):

实现ns/op相对量级
单锁 Table.Bind495.2基准
64 分片 ShardedTable.Bind102.6约 1/4.8

两者都是 64 B/op, 1 allocs/op,也就是说分片省下的是锁竞争而不是内存。同机相对量级,绝对数值不可移植:在 2 核机器上这个差距会小得多,因为瓶颈本来就不是锁。结论是——分片只有在核数够、且登录/顶号确实是热点时才划算;否则单锁表的简单性更值钱。

顶号时的原子替换顺序

Bind 遇到同账号已有活连接时,必须在锁内先把旧连接从表里摘掉,再插新连接,最后在锁外关旧连接。顺序不能反:如果先 Close 旧连接,旧连接的断开回调会调 Remove,而那时新连接可能还没插进去,Remove 就会把账号索引清空——玩家被顶号后变成「在线但不可达」。

更隐蔽的是另一面:即使旧连接在 Bind 里被摘掉了,它的 socket 也不会立刻知道,读写循环还会在几百毫秒后跑一次清理。这个迟到的 Remove 绝不能删掉新会话。防线就是 Remove 里的那个判断——只有账号索引仍然指向自己时才清空它。下面这段测试把这个竞态钉死:

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

// TestOldCloseDoesNotEvictNewSession is the classic takeover race: a second
// login replaces the first, then the first connection finally notices its
// socket died and runs its disconnect path. That path must not delete the new
// session.
func TestOldCloseDoesNotEvictNewSession(t *testing.T) {
	tab := NewTable()

	old := NewConn(nil)
	old.onClose = func(c *Conn) { tab.Remove(c.ID) } // 读循环的清理
	tab.Bind("alice", old)

	fresh := NewConn(nil)
	fresh.onClose = func(c *Conn) { tab.Remove(c.ID) }
	if ev := tab.Bind("alice", fresh); ev != old {
		t.Fatalf("Bind evicted %v, want the old conn", ev)
	}

	_ = old.Close() // 旧 socket 现在才发现自己死了

	if got, ok := tab.Lookup("alice"); !ok || got != fresh {
		t.Fatalf("old Close evicted the new session: got=%v ok=%v", got, ok)
	}
	if _, ok := tab.Get(old.ID); ok {
		t.Fatal("old conn is still indexed after takeover")
	}
}

本节小结

会话表要的是两个方向永远一致,而这靠的不是聪明,是把两个 map 放进同一把锁,并且让每个改表的操作都遵守同一个顺序。Remove 里那句 id == connID 的判断看起来多余,实际上是整张表最不能删的一行——它把「旧连接的迟到清理」和「新会话的有效绑定」区分开了。

go test ./internal/session/ -race 本节 4 个测试全绿(含 400 goroutine 的并发绑定/摘除)。下一节把 Bind 返回的 evicted 用起来:顶号、踢人,以及跨网关的连接迁移。

阅读导航:上一节:目录 · 下一节:7.2 顶号、踢人与连接迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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