《游戏服务端编程实践》20.4 排行榜与社交模块

从联盟成员与职位权限出发,讲清成员上限、联盟科技与帮助的冷却和防刷设计,再讨论排行榜的更新频率、并列名次与赛季重置;给出可测试的 Go 实现,并分析联盟数据与玩家数据不一致导致的线上问题。文章同时讨论跨服联盟的数据归属、帮助请求的幂等键,以及用定时重算和缓存替代实时全量排序的取舍。

一、联盟是长期关系,不是临时组队

SLG 的联盟不是一局游戏里的临时队伍,而是一个会持续数周甚至数月的社会组织。玩家在联盟里捐资源、点科技、互相帮助、一起打仗,联盟的强弱直接影响每个人的游戏体验。服务端设计联盟模块时,首先要接受一个事实:联盟数据和玩家数据是两套生命周期不同的数据,不能指望它们永远在同一个事务里更新。

临时组队通常只需要一个房间对象,成员进出由房间服管理,战斗结束就销毁。联盟则要有独立的 ID、成员名册、职位、科技进度、帮助记录、联盟日志,还要跨登录、跨地图、跨战斗存在。把联盟当成“玩家身上的一个字段”来存,短期能跑,长期一定会遇到查询困难、扩容困难和数据修复困难。

维度临时组队联盟
生命周期一局或一个活动数周到数月
成员关系房间内临时绑定有职位和权限的长期关系
数据归属房间服内存为主独立联盟表,跨服可见
扩容方式按房间分片按联盟 ID 分片,成员索引另存
失败影响重开一局成员丢失、科技回退、排行榜异常

联盟模块的第一条设计原则是“联盟 ID 是聚合根”。成员、职位、科技、帮助次数都以联盟 ID 为分区键,写入时尽量落在同一个存储分区里。玩家侧只保存一个 guild_id 和加入时间,作为反向索引;玩家改名、换头像不影响联盟名册,玩家退盟也只改这个索引和联盟名册两处。第二条原则是“权限在服务端判定”。客户端可以隐藏按钮,但邀请、任免、踢人、解散、修改联盟公告都必须由服务端根据当前职位重新判断。客户端传来的“我是盟主”只能当作提示,不能当作依据。职位变更后,旧请求可能还在路上,服务端要以处理请求时的最新职位为准。

二、成员、职位与上限

一个可维护的职位模型不需要很复杂,通常三层就够:盟主、副盟主、普通成员。层数再多,玩家记不住,客服也解释不清。关键是把每种操作的最低职位写进代码,而不是散落在各个接口里。创建联盟要校验名称唯一、扣除建盟道具、把创建者写成盟主,并做频率限制防止抢注。

操作盟主副盟主普通成员说明
邀请成员允许允许拒绝受成员上限约束
任免职位允许拒绝拒绝不能把别人设为盟主
踢出成员允许仅普通成员拒绝盟主不可被踢
解散联盟允许拒绝拒绝需要二次确认
发起帮助允许允许允许受冷却和日限约束

成员上限一般由“基础上限 + 联盟科技加成 + 赛季buff”组成。上限要在入盟时原子校验,不能先插入成员再检查数量,否则并发邀请会超员。常见做法是把联盟行加锁,或者用条件更新:UPDATE guild SET member_count = member_count + 1 WHERE id = ? AND member_count < member_limit,影响行数为零就返回已满。

跨服联盟是 SLG 里常见的需求,也是最容易出问题的部分。可行的边界是:联盟归属一个“主服”,名册和科技由主服写入;其他服只读缓存,并通过跨服调用提交加入、退出、帮助等命令。不要让两个服同时写同一份联盟名册,否则网络分区时会出现两个盟主。跨服战场可以共享联盟 ID 和展示名,但战争结果由战斗服回传,联盟服只负责结算入账。

三、联盟科技与帮助

联盟科技是典型的“集体投入、全体受益”系统。每次捐赠增加科技点,达到阈值后等级提升,全体成员获得属性加成。服务端要记录三样东西:当前等级、当前等级已投入点数、每个成员的当日捐赠次数。等级和点数放在联盟聚合里,捐赠次数可以放在成员维度,但要在同一个逻辑事务里更新,避免“扣了资源没加点”。科技加成不要直接写进玩家属性表。玩家属性每次上线时由“基础属性 + 联盟科技 + 装备 + buff”实时计算,联盟科技变化后,在线玩家通过推送刷新。把加成固化到玩家表里,科技回退或联盟解散时就要全量扫描修复,代价很高。

帮助系统看起来简单,实际上最容易被工作室利用。一个帮助请求通常有三个约束:单个请求最多被帮助多少次、同一个帮助者对同一请求的冷却、每个帮助者每天的总次数。只做其中一个都不够:只限次数,工作室可以开小号轮流点;只做冷却,玩家可以换目标无限刷;只做日限,单个请求会被瞬间刷满。

约束作用典型配置(示例值)绕过方式
单请求帮助上限控制请求完成速度10 次多开小号同时点
帮助者目标冷却限制同一对人重复互助10 分钟换目标继续刷
帮助者日限控制单账号总产出20 次多账号轮换
请求有效期防止僵尸请求长期存在8 小时无
服务端时间防止改本地时间UTC改客户端时间无效

帮助请求要有唯一 ID,帮助动作要幂等。玩家网络抖动重发时,服务端不能重复计数;可以用“请求 ID + 帮助者 ID”做唯一键,插入成功才计数。请求完成后状态改为已完成,后续帮助直接返回“已结束”,而不是继续累加。

四、排行榜:更新频率、并列与赛季

排行榜的难点不在排序算法,而在“什么时候排、排多少、并列怎么算、赛季怎么切”。实时全量排序在玩家基数大时不可行:每次战力变化都重排几百万条记录,数据库和 CPU 都受不了。更常见的做法是写入分数、定期计算、缓存前 N 名。

榜单数据来源更新方式建议频率(示例值)并列规则
个人战力榜玩家属性快照定时任务重算5 分钟同分按玩家 ID 升序
击杀榜战斗结算事件增量累加后重算1 分钟同分按达成时间先后
联盟榜成员战力/积分汇总定时任务重算5 分钟同分按联盟 ID 升序
赛季榜赛季积分事件驱动 + 缓存30 秒同分共享名次

并列名次要用固定规则,不能依赖数据库的返回顺序。推荐“竞赛排名”:分数 100、100、90 对应第 1、1、3 名,而不是 1、2、3。这样玩家看到两个第一名时不会质疑“为什么他排我前面”。同分内部再用玩家 ID 或达成时间做稳定排序,保证每次查询结果一致,也方便测试。

赛季重置要提前公告,并在服务端用统一时间点。重置不是简单删表:历史榜单要归档,赛季奖励要按结算时的名次发放,玩家新赛季的初始分要按规则衰减。重置过程要可重入,任务中断后能继续;发放奖励要有幂等键,避免重复发。重置期间最好冻结榜单写入,或者把写入落到新赛季分区,防止边界时刻的分数丢失。

五、失败场景:联盟数据与玩家数据不一致

某 SLG 上线联盟转让功能后,客服收到反馈:玩家 A 已经被踢出联盟,但个人界面仍显示联盟,且还能领取联盟帮助。排查发现,踢人接口先删了联盟名册,再异步更新玩家表的 guild_id;异步任务失败后没有重试,玩家表一直保留旧值。更糟的是,帮助接口只校验玩家表里的 guild_id,没有回查联盟名册,于是被踢玩家继续帮助自己。

这个问题的根因不是某一个接口写错,而是“联盟名册”和“玩家索引”被当成两份可以独立演进的数据,却没有一致性保障。修复分三层。第一,所有读路径以联盟名册为准,玩家表的 guild_id 只作为索引,发现不一致时以名册覆盖索引。第二,踢人、退盟、解散走同一个领域服务,先写名册,再发事务消息更新索引,消息失败进入重试队列。第三,增加对账任务,每天扫描 guild_id 指向不存在联盟、或名册成员与索引不一致的记录,输出修复清单。对账任务本身也要小心。不要直接批量改线上数据,先生成差异报告,抽样确认后再分批修复;修复操作要幂等,记录操作人和原因。对已经发放的帮助奖励,不要追回,按“多给不追、少给补发”的运营口径处理,避免二次投诉。

六、Go 实现:social.go

下面的实现把联盟成员、职位判定、帮助计数和排行榜放在一个包里。Guild 的成员、职位、帮助记录共用一把锁,保证“退盟后不能再帮助”这类判断不会看到中间态;Board 只在查询时对分数快照排序,写入路径不做全量排序。

// file: ch20/social/social.go
// tier: L1
// verify: go test ./ch20/social/

package social

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

const (
	DefaultMemberLimit  = 50
	DefaultHelpLimit    = 20
	DefaultHelpCooldown = 10 * time.Minute
)

var (
	ErrInvalidPlayer   = errors.New("social: player id is empty")
	ErrInvalidRole     = errors.New("social: invalid role assignment")
	ErrInvalidPolicy   = errors.New("social: invalid help policy")
	ErrGuildFull       = errors.New("social: guild member limit reached")
	ErrDuplicateMember = errors.New("social: player already joined")
	ErrNotMember       = errors.New("social: player is not a guild member")
	ErrForbidden       = errors.New("social: role is not allowed")
	ErrLeaderImmutable = errors.New("social: leader cannot be removed or demoted")
	ErrHelpSelf        = errors.New("social: cannot help yourself")
	ErrHelpCooldown    = errors.New("social: help is still in cooldown")
	ErrHelpLimit       = errors.New("social: daily help limit reached")
	ErrInvalidScore    = errors.New("social: score must not be negative")
	ErrSeasonBackwards = errors.New("social: season must move forward")
)

// Role 的数值只用于比较权限,不用于对外展示。
type Role int

const (
	RoleMember Role = iota
	RoleElder
	RoleLeader
)

func (r Role) canInvite() bool { return r >= RoleElder }
func (r Role) canAssign() bool { return r == RoleLeader }

// Guild 把成员、职位和互助计数放在同一把锁下,避免出现
// “已经退盟但还能帮助”的中间态。
type Guild struct {
	mu           sync.Mutex
	id           string
	server       string
	memberLimit  int
	helpLimit    int
	helpCooldown time.Duration
	members      map[string]Role
	helpAt       map[string]time.Time
	helpDay      map[string]string
	helpCount    map[string]int
}

func NewGuild(id, server, leader string, memberLimit int) (*Guild, error) {
	if id == "" || leader == "" {
		return nil, ErrInvalidPlayer
	}
	if memberLimit <= 0 {
		memberLimit = DefaultMemberLimit
	}
	return &Guild{
		id: id, server: server, memberLimit: memberLimit,
		helpLimit: DefaultHelpLimit, helpCooldown: DefaultHelpCooldown,
		members:   map[string]Role{leader: RoleLeader},
		helpAt:    make(map[string]time.Time),
		helpDay:   make(map[string]string),
		helpCount: make(map[string]int),
	}, nil
}

// SetHelpPolicy 由运营配置调用,测试用短冷却验证边界。
func (g *Guild) SetHelpPolicy(limit int, cooldown time.Duration) error {
	if limit <= 0 || cooldown < 0 {
		return ErrInvalidPolicy
	}
	g.mu.Lock()
	defer g.mu.Unlock()
	g.helpLimit, g.helpCooldown = limit, cooldown
	return nil
}

// AddMember 处理入盟:副盟主及以上可以邀请,职位只能是成员或副盟主。
func (g *Guild) AddMember(actor, player string, role Role) error {
	if player == "" {
		return ErrInvalidPlayer
	}
	g.mu.Lock()
	defer g.mu.Unlock()
	actorRole, ok := g.members[actor]
	if !ok {
		return ErrNotMember
	}
	if !actorRole.canInvite() {
		return ErrForbidden
	}
	if role >= RoleLeader {
		return ErrInvalidRole
	}
	if _, exists := g.members[player]; exists {
		return ErrDuplicateMember
	}
	if len(g.members) >= g.memberLimit {
		return ErrGuildFull
	}
	g.members[player] = role
	return nil
}

// SetRole 只有盟主可以调用,且不能把成员设为盟主。
func (g *Guild) SetRole(actor, target string, role Role) error {
	if role >= RoleLeader {
		return ErrInvalidRole
	}
	g.mu.Lock()
	defer g.mu.Unlock()
	actorRole, ok := g.members[actor]
	if !ok {
		return ErrNotMember
	}
	if !actorRole.canAssign() {
		return ErrForbidden
	}
	targetRole, ok := g.members[target]
	if !ok {
		return ErrNotMember
	}
	if targetRole == RoleLeader {
		return ErrLeaderImmutable
	}
	if actor == target {
		return ErrForbidden
	}
	g.members[target] = role
	return nil
}

// Help 记录一次联盟帮助。冷却按“帮助者 + 被帮助者”计算,日限按帮助者
// 计算并以 UTC 自然日重置,避免玩家跨时区刷次数。
func (g *Guild) Help(helper, target string, now time.Time) error {
	if helper == "" || target == "" {
		return ErrInvalidPlayer
	}
	if helper == target {
		return ErrHelpSelf
	}
	g.mu.Lock()
	defer g.mu.Unlock()
	if _, ok := g.members[helper]; !ok {
		return ErrNotMember
	}
	if _, ok := g.members[target]; !ok {
		return ErrNotMember
	}
	key := helper + "|" + target
	if last, ok := g.helpAt[key]; ok && now.Sub(last) < g.helpCooldown {
		return ErrHelpCooldown
	}
	day := now.UTC().Format("2006-01-02")
	if g.helpDay[helper] != day {
		g.helpDay[helper] = day
		g.helpCount[helper] = 0
	}
	if g.helpCount[helper] >= g.helpLimit {
		return ErrHelpLimit
	}
	g.helpAt[key] = now
	g.helpCount[helper]++
	return nil
}

// Ranked 是排行榜的一行。并列使用竞赛排名(1、1、3),同分时按
// 玩家 ID 升序输出,保证每次查询顺序稳定。
type Ranked struct {
	Rank   int
	Player string
	Score  int64
}

// Board 不保存全量排序结果,查询时才对当前分数快照排序。
// 联盟榜可以复用同一结构,把 Player 换成联盟 ID 即可。
type Board struct {
	mu     sync.Mutex
	season int
	scores map[string]int64
}

func NewBoard(season int) *Board {
	return &Board{season: season, scores: make(map[string]int64)}
}

func (b *Board) Set(player string, score int64) error {
	if player == "" {
		return ErrInvalidPlayer
	}
	if score < 0 {
		return ErrInvalidScore
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	b.scores[player] = score
	return nil
}

func (b *Board) Top(n int) []Ranked {
	b.mu.Lock()
	defer b.mu.Unlock()
	entries := make([]Ranked, 0, len(b.scores))
	for player, score := range b.scores {
		entries = append(entries, Ranked{Player: player, Score: score})
	}
	sort.Slice(entries, func(i, j int) bool {
		if entries[i].Score != entries[j].Score {
			return entries[i].Score > entries[j].Score
		}
		return entries[i].Player < entries[j].Player
	})
	rank := 0
	var prev int64
	for i := range entries {
		if i == 0 || entries[i].Score != prev {
			rank = i + 1
			prev = entries[i].Score
		}
		entries[i].Rank = rank
	}
	if n <= 0 || n > len(entries) {
		n = len(entries)
	}
	return entries[:n]
}

// ResetSeason 清空分数并推进赛季;赛季只能前进,防止误操作回滚榜单。
func (b *Board) ResetSeason(season int) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	if season <= b.season {
		return ErrSeasonBackwards
	}
	b.season = season
	b.scores = make(map[string]int64)
	return nil
}

AddMember 先校验操作者职位,再检查重复、职位合法性和成员上限;SetRole 只允许盟主调用,且不能把成员设为盟主。帮助逻辑先查双方是否仍在名册,再按“帮助者 + 目标”检查冷却,最后按 UTC 自然日累计次数;任何一步失败都不会修改计数。Top 使用竞赛排名:排序后只有分数变化时才推进名次,因此 100、100、90 会得到 1、1、3。同分按玩家 ID 升序,保证结果稳定。ResetSeason 只允许赛季号前进,清空当前分数;历史归档和奖励发放应由上层任务在调用前完成。

七、测试与运行

测试覆盖四类行为:普通成员不能邀请、副盟主不能任免、盟主可以任免;成员上限拒绝超额加入;帮助冷却、日限、跨日重置和自帮助拒绝;排行榜并列名次与赛季只能前进。gofmt -l 无输出表示格式一致,go vet 无输出表示静态检查通过;测试不依赖数据库和真实时钟,所有时间由调用方传入,因此可以在 CI 里稳定重复运行。

cd .local/checks/gs-practice-lab && gofmt -l ch20/social && go vet ./ch20/social/ && go test ./ch20/social/ -count=1
ok  	gspractice/ch20/social	0.312s

小结

联盟模块要以联盟 ID 为聚合根,把名册、职位、科技和帮助计数放在同一一致性边界内;权限必须在服务端按最新职位判定,成员上限要原子校验。帮助系统要同时限制单请求次数、帮助者目标冷却和日限,并用请求 ID 保证幂等。排行榜采用“写分数、定时重算、缓存前 N”的路线,并列使用稳定规则,赛季重置要可重入、可归档、奖励幂等。联盟名册与玩家索引之间要有对账和修复机制,不能假设两次写入永远同时成功。

常见错误与边界

  • 把联盟当成玩家表里的一个字段,成员、科技、帮助都挂在玩家身上,联盟查询和扩容很快失控。
  • 邀请时先插入成员再检查上限,并发邀请导致联盟超员,后续只能手工踢人。
  • 只在客户端隐藏按钮,服务端不校验职位,普通成员可以构造请求任免盟主。
  • 帮助只做日限不做目标冷却,工作室用多个小号轮流刷同一个请求。
  • 帮助接口不幂等,网络重发导致一次帮助计数两次,日限被提前耗尽。
  • 排行榜每次分数变化都全量排序,玩家基数上来后数据库 CPU 被打满。
  • 并列名次依赖数据库返回顺序,同一分数在不同查询里排名不同,引发玩家投诉。
  • 赛季重置直接删表,历史榜单和奖励记录无法追溯,出问题无法回滚。
  • 踢人只删联盟名册、不更新玩家索引,或反之,被踢玩家仍能领取联盟福利。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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