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.Bind | 495.2 | 基准 |
64 分片 ShardedTable.Bind | 102.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 顶号、踢人与连接迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。