《游戏服务端编程实践》4.4.3 登录态存储与踢下线机制

把登录态从 token 落到会话表:设计会话字段、单端与多端策略、登录服到网关的踢下线链路,以及断线重连的复验规则;附可运行的 Go Registry,覆盖顶号、设备并存、会话上限与回调只触发一次,并说明多网关场景下的通知幂等、对账与失败恢复。

一、会话记录存什么

登录态不能只存在 JWT 里。JWT 自包含、到期前无法单独撤回,而“顶号”“封禁”“管理员踢人”都要求立即生效。登录服必须维护一张会话表,token 只携带定位信息,真正的有效性以会话表为准。

字段说明
account_id账号主键,跨区稳定
session_id本次登录的唯一 ID,token 的 sid
device_id安装实例或设备指纹,用于同设备顶号
gateway_id当前承载长连接的网关
login_at / last_seen登录时间与最近活跃时间
version会话版本,每次踢下线递增

会话表是事实源,网关只缓存“哪个连接属于哪个 session”。缓存可以丢,丢了向登录服重建;登录服丢了,所有在线玩家都会被误判下线,所以它必须持久化并多副本。

二、单端还是多端

策略同设备再次登录不同设备典型场景
单会话顶掉旧会话顶掉旧会话单角色页游、竞技
按设备顶掉旧会话允许并存手机 + 平板
多端限额顶掉旧会话允许并存,超限拒绝多端养成、直播小号

策略要配置化,不要写死。同一款游戏在不同渠道可能有不同诉求:主渠道允许多端,买量渠道为了防止共享账号只允许单端。配置变更要能按账号灰度,而不是全服一刀切。

三、踢下线链路

踢下线不是“登录服删一行记录”就结束,必须走到连接层:

  1. 登录服把会话 version 加一,写入会话表或墓碑。
  2. 通过控制通道把 KICK(session_id, version, reason) 通知给 gateway_id 对应的网关。
  3. 网关找到该会话的连接,推送 KICK 消息,等待发送缓冲排空后关闭连接。
  4. 客户端收到 KICK 后清理本地凭证并回到登录页,不能用旧凭证静默重连。

只靠客户端轮询“我还在线吗”不可靠:弱网、后台挂起、外挂改包都会让轮询失真。服务端必须在版本变化时主动推送,连接关闭是最终兜底。

四、断线重连不是免死金牌

重连凭证与登录态要分离。重连凭证只证明“这个连接之前属于某个会话”,不能证明“这个会话现在仍然有效”。网关处理重连时必须再向登录服 Validate,并比较凭证里的 version 与会话表当前版本。

被踢的会话不允许静默重连:会话已删除、墓碑版本已递增,重连请求应返回“会话已失效”,客户端只能重新登录。否则玩家被踢后立刻自动重连,踢下线就成了摆设。

五、多网关一致性

会话表必须放在共享存储(Redis、数据库或专用会话服务),不能每个网关各存一份。踢下线通知走广播或按 gateway_id 定向投递;每个网关只关闭自己持有的连接,不负责修改会话版本;通知要幂等:同一个 session_id + version 重复到达,网关只执行一次关闭。网关崩溃重启后,从登录服拉取本网关的在线会话做对账,发现版本不一致就补踢。只发一次通知、不做重试和对账,迟早会出现“登录服认为已踢、网关还连着”的幽灵会话。

六、失败场景:只踢了本网关

某玩家在 A 网关登录,顶号请求落到 B 网关。B 网关只在本地内存里删掉旧连接,A 网关毫不知情,旧连接继续收发消息。玩家看到“新设备已登录”,旧设备却还能玩,直到 token 自然过期;根因是把“会话存储”放在了网关本地。修复分三步:会话版本写共享存储、踢下线事件广播到所有网关、连接关闭结果回报登录服。任何一步缺失,都会留下不一致窗口。

七、Go 实现

下面的 Registry 是登录服侧的会话事实源:Login 按策略顶号,Kick 递增版本并触发一次回调,Validate 同时检查会话存在与版本一致。回调代表“通知网关”,不是直接写 socket。

// file: ch04/login/session_registry.go
// tier: L1
// verify: go test ./ch04/login/

package login

import (
	"errors"
	"fmt"
	"sort"
	"sync"
	"time"
)

type LoginPolicy int

const (
	SingleSession LoginPolicy = iota // 同账号只保留一个会话
	PerDevice                        // 每台设备保留一个会话
	MultiDevice                      // 多端并存,受 MaxSessions 限制
)

type Session struct {
	AccountID uint64
	SessionID string
	DeviceID  string
	GatewayID string
	LoginAt   time.Time
	LastSeen  time.Time
	Version   uint64
}

type KickEvent struct {
	AccountID uint64
	SessionID string
	Version   uint64
	Reason    string
}

type RegistryConfig struct {
	Policy      LoginPolicy
	MaxSessions int
}

var (
	ErrSessionNotFound = errors.New("session not found")
	ErrSessionLimit    = errors.New("session limit reached")
	ErrInvalidLogin    = errors.New("account and device are required")
)

// Registry 是登录服的会话事实源;网关只缓存路由,不自行判定登录态。
type Registry struct {
	mu       sync.Mutex
	cfg      RegistryConfig
	now      func() time.Time
	nextID   uint64
	sessions map[string]*Session
	versions map[string]uint64 // 含已踢下线的墓碑,用于拒绝旧凭证
	notify   func(KickEvent)
}

func NewRegistry(cfg RegistryConfig, notify func(KickEvent)) *Registry {
	if cfg.MaxSessions <= 0 {
		cfg.MaxSessions = 1
	}
	return &Registry{cfg: cfg, now: time.Now, sessions: map[string]*Session{}, versions: map[string]uint64{}, notify: notify}
}

func (r *Registry) Login(accountID uint64, deviceID, gatewayID string) (*Session, error) {
	if accountID == 0 || deviceID == "" {
		return nil, ErrInvalidLogin
	}
	r.mu.Lock()
	var events []KickEvent
	live := r.liveLocked(accountID)
	switch r.cfg.Policy {
	case SingleSession:
		events = r.kickLocked(live, "replaced")
	case PerDevice:
		var same []*Session
		for _, s := range live {
			if s.DeviceID == deviceID {
				same = append(same, s)
			}
		}
		events = r.kickLocked(same, "replaced")
	case MultiDevice:
		if len(live) >= r.cfg.MaxSessions {
			r.mu.Unlock()
			return nil, ErrSessionLimit
		}
	}
	r.nextID++
	now := r.now()
	s := &Session{AccountID: accountID, SessionID: fmt.Sprintf("sess_%d_%d", accountID, r.nextID), DeviceID: deviceID, GatewayID: gatewayID, LoginAt: now, LastSeen: now, Version: 1}
	r.sessions[s.SessionID] = s
	r.versions[s.SessionID] = s.Version
	out := *s
	r.mu.Unlock()
	for _, e := range events {
		r.emit(e)
	}
	return &out, nil
}

func (r *Registry) Kick(sessionID, reason string) error {
	r.mu.Lock()
	s, ok := r.sessions[sessionID]
	if !ok {
		r.mu.Unlock()
		return ErrSessionNotFound
	}
	events := r.kickLocked([]*Session{s}, reason)
	r.mu.Unlock()
	for _, e := range events {
		r.emit(e)
	}
	return nil
}

func (r *Registry) Validate(sessionID string) (*Session, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.sessions[sessionID]
	if !ok || s.Version != r.versions[sessionID] {
		return nil, ErrSessionNotFound
	}
	out := *s
	return &out, nil
}

// Version 返回会话当前版本;会话被踢后墓碑仍在,返回递增后的版本。
func (r *Registry) Version(sessionID string) (uint64, bool) {
	r.mu.Lock()
	defer r.mu.Unlock()
	v, ok := r.versions[sessionID]
	return v, ok
}

func (r *Registry) List(accountID uint64) []Session {
	r.mu.Lock()
	defer r.mu.Unlock()
	out := make([]Session, 0, len(r.sessions))
	for _, s := range r.sessions {
		if s.AccountID == accountID {
			out = append(out, *s)
		}
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].LoginAt.Equal(out[j].LoginAt) {
			return out[i].SessionID < out[j].SessionID
		}
		return out[i].LoginAt.Before(out[j].LoginAt)
	})
	return out
}

func (r *Registry) liveLocked(accountID uint64) []*Session {
	var out []*Session
	for _, s := range r.sessions {
		if s.AccountID == accountID {
			out = append(out, s)
		}
	}
	return out
}

func (r *Registry) kickLocked(list []*Session, reason string) []KickEvent {
	if reason == "" {
		reason = "kicked"
	}
	events := make([]KickEvent, 0, len(list))
	for _, s := range list {
		r.versions[s.SessionID]++
		delete(r.sessions, s.SessionID)
		events = append(events, KickEvent{AccountID: s.AccountID, SessionID: s.SessionID, Version: r.versions[s.SessionID], Reason: reason})
	}
	return events
}

func (r *Registry) emit(e KickEvent) {
	if r.notify != nil {
		r.notify(e)
	}
}

测试覆盖单会话顶号、按设备并存、多端上限,以及重复踢只触发一次回调:

// file: ch04/login/session_registry_test.go
// tier: L1
// verify: go test ./ch04/login/

package login

import (
	"errors"
	"testing"
	"time"
)

func newRegistry(cfg RegistryConfig, notify func(KickEvent)) *Registry {
	r := NewRegistry(cfg, notify)
	r.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
	return r
}

func TestSingleSessionReplacesOldDevice(t *testing.T) {
	var events []KickEvent
	r := newRegistry(RegistryConfig{Policy: SingleSession}, func(e KickEvent) { events = append(events, e) })
	old, _ := r.Login(7, "dev-a", "gw-1")
	replacement, _ := r.Login(7, "dev-b", "gw-2")
	if _, err := r.Validate(old.SessionID); !errors.Is(err, ErrSessionNotFound) {
		t.Fatalf("old session still valid: %v", err)
	}
	if _, err := r.Validate(replacement.SessionID); err != nil {
		t.Fatalf("replacement invalid: %v", err)
	}
	if len(events) != 1 || events[0].SessionID != old.SessionID || events[0].Reason != "replaced" {
		t.Fatalf("unexpected kick events: %+v", events)
	}
	if v, ok := r.Version(old.SessionID); !ok || v != old.Version+1 {
		t.Fatalf("old version=%d/%v, want %d", v, ok, old.Version+1)
	}
}

func TestPerDeviceKeepsOtherDevices(t *testing.T) {
	r := newRegistry(RegistryConfig{Policy: PerDevice}, nil)
	a1, _ := r.Login(7, "dev-a", "gw-1")
	b, _ := r.Login(7, "dev-b", "gw-2")
	a2, _ := r.Login(7, "dev-a", "gw-3")
	if _, err := r.Validate(a1.SessionID); !errors.Is(err, ErrSessionNotFound) {
		t.Fatal("same device should be replaced")
	}
	for _, s := range []*Session{b, a2} {
		if _, err := r.Validate(s.SessionID); err != nil {
			t.Fatalf("session %s should survive: %v", s.SessionID, err)
		}
	}
	if got := len(r.List(7)); got != 2 {
		t.Fatalf("sessions=%d, want 2", got)
	}
}

func TestMultiDeviceLimit(t *testing.T) {
	r := newRegistry(RegistryConfig{Policy: MultiDevice, MaxSessions: 2}, nil)
	first, _ := r.Login(7, "dev-a", "gw-1")
	second, _ := r.Login(7, "dev-b", "gw-2")
	if _, err := r.Login(7, "dev-c", "gw-3"); !errors.Is(err, ErrSessionLimit) {
		t.Fatalf("third login error=%v, want ErrSessionLimit", err)
	}
	for _, s := range []*Session{first, second} {
		if _, err := r.Validate(s.SessionID); err != nil {
			t.Fatalf("session %s should survive: %v", s.SessionID, err)
		}
	}
	if got := len(r.List(7)); got != 2 {
		t.Fatalf("sessions=%d, want 2", got)
	}
}

func TestKickNotifiesExactlyOnce(t *testing.T) {
	notified := 0
	r := newRegistry(RegistryConfig{Policy: MultiDevice, MaxSessions: 2}, func(KickEvent) { notified++ })
	s, _ := r.Login(7, "dev-a", "gw-1")
	if err := r.Kick(s.SessionID, "admin"); err != nil {
		t.Fatal(err)
	}
	if err := r.Kick(s.SessionID, "admin"); !errors.Is(err, ErrSessionNotFound) {
		t.Fatalf("second kick error=%v, want ErrSessionNotFound", err)
	}
	if notified != 1 {
		t.Fatalf("notifications=%d, want 1", notified)
	}
	if _, err := r.Validate(s.SessionID); !errors.Is(err, ErrSessionNotFound) {
		t.Fatalf("kicked session still valid: %v", err)
	}
	if v, ok := r.Version(s.SessionID); !ok || v != s.Version+1 {
		t.Fatalf("version=%d/%v, want %d", v, ok, s.Version+1)
	}
}

八、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch04/login && go vet ./ch04/login/ && go test ./ch04/login/ -count=1
ok  	gspractice/ch04/login	0.405s

小结

登录态的核心是“token 负责携带,会话表负责裁决”。字段里必须有 session_id、gateway_id 和 version,否则既定位不到连接,也无法让踢下线立即生效;踢下线要版本先行、通知随后、连接关闭兜底;断线重连必须复验版本。多网关场景下,共享存储、幂等通知和对账缺一不可。

常见错误与边界

  • 只在网关内存里删连接,其他网关上的同一会话仍然存活。
  • 踢下线不递增版本,旧 token 在过期前继续可用。
  • 重连不复验会话,被踢玩家自动重连回到游戏。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist