一、会话记录存什么
登录态不能只存在 JWT 里。JWT 自包含、到期前无法单独撤回,而“顶号”“封禁”“管理员踢人”都要求立即生效。登录服必须维护一张会话表,token 只携带定位信息,真正的有效性以会话表为准。
| 字段 | 说明 |
|---|---|
account_id | 账号主键,跨区稳定 |
session_id | 本次登录的唯一 ID,token 的 sid |
device_id | 安装实例或设备指纹,用于同设备顶号 |
gateway_id | 当前承载长连接的网关 |
login_at / last_seen | 登录时间与最近活跃时间 |
version | 会话版本,每次踢下线递增 |
会话表是事实源,网关只缓存“哪个连接属于哪个 session”。缓存可以丢,丢了向登录服重建;登录服丢了,所有在线玩家都会被误判下线,所以它必须持久化并多副本。
二、单端还是多端
| 策略 | 同设备再次登录 | 不同设备 | 典型场景 |
|---|---|---|---|
| 单会话 | 顶掉旧会话 | 顶掉旧会话 | 单角色页游、竞技 |
| 按设备 | 顶掉旧会话 | 允许并存 | 手机 + 平板 |
| 多端限额 | 顶掉旧会话 | 允许并存,超限拒绝 | 多端养成、直播小号 |
策略要配置化,不要写死。同一款游戏在不同渠道可能有不同诉求:主渠道允许多端,买量渠道为了防止共享账号只允许单端。配置变更要能按账号灰度,而不是全服一刀切。
三、踢下线链路
踢下线不是“登录服删一行记录”就结束,必须走到连接层:
- 登录服把会话
version加一,写入会话表或墓碑。 - 通过控制通道把
KICK(session_id, version, reason)通知给gateway_id对应的网关。 - 网关找到该会话的连接,推送
KICK消息,等待发送缓冲排空后关闭连接。 - 客户端收到
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 在过期前继续可用。
- 重连不复验会话,被踢玩家自动重连回到游戏。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。