《游戏服务端编程实践》10.2.3 社交互动与排行榜

从赠送体力、点赞和组队邀请的共同骨架出发,讨论关系校验、频率限制、收益上限与幂等键,再比较全服、好友、公会排行榜的数据来源和读写成本,说明密集并列名次、稳定排序与赛季重置,并给出维护 TopN 的 RankBoard 实现和测试,同时指出读放大、缓存与赛季版本失效的边界。

一、互动功能的共同骨架

赠送体力、点赞、组队邀请看起来是三件事,服务端骨架却相同:校验关系、校验频率、写一次业务记录、返回剩余次数。把这三步拆开写在每个接口里,最后一定有人漏掉关系校验或频率校验。

建议统一成“动作 + 目标 + 幂等键”的请求模型。动作决定消耗和奖励,目标决定关系检查,幂等键防止客户端重试造成重复赠送。所有互动都先过关系层,再过风控层,最后落业务表。

二、频率限制与防刷

维度限制方式作用注意
单玩家令牌桶或滑动窗口限制整体操作速度窗口要按服务器时间
单目标每目标每分钟 N 次防止刷同一好友N 要可配置
单对关系双向计数防止互相刷赠送与回赠分开算
账号维度设备、IP、账号聚合防小号误伤要有人工申诉

频率限制要放在业务写入之前,并且失败时不消耗次数。只按连接限速不够:玩家可以重连绕过;只按 IP 限速会误伤同一网吧的玩家。多维度叠加时,先做便宜的计数,再做昂贵的风控查询。

防刷还要考虑“收益上限”。例如每天最多赠送 10 次体力、单个好友每天最多收到 3 次。上限和频率是两件事:频率限制瞬时速度,上限限制全天总量。

三、排行榜的三种范围

范围数据来源更新频率主要用途
全服全服分数表秒级或分钟级赛季排名、展示
好友好友关系加分数读取时合并好友间比较
公会公会成员分数聚合定时或事件驱动公会战、周常

全服榜适合用有序集合维护;好友榜不要单独建一份全量榜,而是先取好友 ID,再批量查分数;公会榜可以按公会维度做聚合,但成员变动后要及时重算,否则退会玩家的分数还挂在旧公会。

排行榜的读放大通常比写更大。首页、结算页、活动页可能同时拉 TopN 和自己的名次,必须做缓存或本地副本,并接受短时间的不一致。展示“第 1 名”比展示“精确到毫秒的分数变化”更重要。

四、分数更新、并列与赛季重置

分数更新有两个语义:覆盖和累加。赛季榜一般用累加,活动榜可能用覆盖。接口要明确是 SetScore 还是 AddScore,不能同名同参却行为不同。

并列名次推荐密集名次:100、100、90 对应第 1、第 1、第 2。很多实现用竞赛名次,第三名直接变成第 3,玩家会以为中间少了一个人。若产品坚持竞赛名次,要在协议和 UI 上写清楚,不能两套口径混用。

同分排序还要有稳定的第二关键字,通常用玩家 ID 升序。否则每次读取顺序可能变化,客户端分页会重复或漏人。赛季重置要同时清空分数、名次缓存和“我的排名”缓存;只清分数表,旧名次会被继续返回。

五、有序集合还是定时快照

方案写入成本读取成本一致性适用
有序集合O(log N)TopN 快强实时榜、全服榜
排序切片O(N) 插入TopN 快进程内强单进程、中小规模
定时快照低读快照最终一致展示型榜单
每次全量排序高高强只适合离线任务

有序集合适合 Redis ZSET 这类外部存储,写入和范围查询都由存储负责。单进程服务可以用排序切片:写入要移动元素,但读取 TopN 只遍历前 N 个;规模到十万以上时,写入放大需要重新评估。

定时快照把“计算榜单”和“展示榜单”分开,适合更新不频繁的活动榜。代价是延迟:玩家刚得分,榜单可能几分钟后才变化。要明确告诉客户端刷新周期,避免玩家以为分数没到账。

六、失败场景:每次请求都全量排序

活动上线后,首页每次刷新都从数据库拉全服玩家分数,在应用层排序后取前 100。平时没事,开服活动一开,首页 QPS 上涨,数据库连接被打满,排行榜接口超时,连带登录和背包接口一起变慢。

修复不是加机器,而是把排序从读路径移走:写入时维护有序结构,或定时生成快照;读路径只取前 N 个和当前玩家名次。名次查询也要避免全表扫描,可以在快照里维护“分数到名次”的映射。

另一个常见问题是赛季重置只清了榜单,没有清“我的名次”缓存。玩家在新赛季看到上赛季的第 7 名,实际分数却是 0。重置必须是一个原子操作,或者给缓存加赛季版本号。

七、Go 实现:RankBoard

下面的实现用有序切片维护分数,按“分数降序、玩家 ID 升序”排列。Top 返回密集名次,Rank 单独查询某个玩家的名次。

// file: ch10/social/rank.go
// tier: L1
// verify: go test ./ch10/social/

package social

import (
	"sort"
	"sync"
)

// RankEntry 是排行榜对外返回的一行,Rank 采用密集名次:1、1、2,而不是 1、1、3。
type RankEntry struct {
	PlayerID uint64
	Score    int64
	Rank     int
}

type rankItem struct {
	playerID uint64
	score    int64
}

// RankBoard 用有序切片维护分数:写入 O(n),读取 TopN 只遍历前 N 个元素。
type RankBoard struct {
	mu     sync.Mutex
	season string
	items  []rankItem // 按分数降序,同分按玩家 ID 升序
	scores map[uint64]int64
}

func NewRankBoard(season string) *RankBoard {
	return &RankBoard{season: season, scores: make(map[uint64]int64)}
}

// Update 覆盖玩家分数;已有记录先删除再按新分数插入,避免残留旧位置。
func (b *RankBoard) Update(playerID uint64, score int64) {
	b.mu.Lock()
	defer b.mu.Unlock()
	if i := b.indexOfLocked(playerID); i >= 0 {
		b.items = append(b.items[:i], b.items[i+1:]...)
	}
	b.insertLocked(rankItem{playerID: playerID, score: score})
	b.scores[playerID] = score
}

func (b *RankBoard) Add(playerID uint64, delta int64) int64 {
	b.mu.Lock()
	b.scores[playerID] += delta
	score := b.scores[playerID]
	if i := b.indexOfLocked(playerID); i >= 0 {
		b.items = append(b.items[:i], b.items[i+1:]...)
	}
	b.insertLocked(rankItem{playerID: playerID, score: score})
	b.mu.Unlock()
	return score
}

func (b *RankBoard) Score(playerID uint64) (int64, bool) {
	b.mu.Lock()
	defer b.mu.Unlock()
	score, ok := b.scores[playerID]
	return score, ok
}

// Rank 返回密集名次:同分同名次,下一名只加一。
func (b *RankBoard) Rank(playerID uint64) (int, bool) {
	b.mu.Lock()
	defer b.mu.Unlock()
	rank := 0
	var prev int64
	for i, item := range b.items {
		if i == 0 || item.score != prev {
			rank++
			prev = item.score
		}
		if item.playerID == playerID {
			return rank, true
		}
	}
	return 0, false
}

// Top 返回前 n 名;n 小于等于 0 时返回 nil,n 超过总人数时返回全部。
func (b *RankBoard) Top(n int) []RankEntry {
	b.mu.Lock()
	defer b.mu.Unlock()
	if n <= 0 {
		return nil
	}
	if n > len(b.items) {
		n = len(b.items)
	}
	out := make([]RankEntry, 0, n)
	rank := 0
	var prev int64
	for i, item := range b.items {
		if len(out) == n {
			break
		}
		if i == 0 || item.score != prev {
			rank++
			prev = item.score
		}
		out = append(out, RankEntry{PlayerID: item.playerID, Score: item.score, Rank: rank})
	}
	return out
}

func (b *RankBoard) Len() int {
	b.mu.Lock()
	defer b.mu.Unlock()
	return len(b.items)
}

func (b *RankBoard) Season() string {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.season
}

// Reset 切换赛季时同时清空分数索引,避免旧赛季分数被下一次 Add 读出来。
func (b *RankBoard) Reset(season string) {
	b.mu.Lock()
	b.season = season
	b.items = nil
	b.scores = make(map[uint64]int64)
	b.mu.Unlock()
}

func (b *RankBoard) indexOfLocked(playerID uint64) int {
	for i := range b.items {
		if b.items[i].playerID == playerID {
			return i
		}
	}
	return -1
}

func (b *RankBoard) insertLocked(item rankItem) {
	i := sort.Search(len(b.items), func(i int) bool {
		if b.items[i].score != item.score {
			return b.items[i].score < item.score
		}
		return b.items[i].playerID > item.playerID
	})
	b.items = append(b.items, rankItem{})
	copy(b.items[i+1:], b.items[i:])
	b.items[i] = item
}

Update 先删除旧位置再插入新位置,避免同一个玩家出现两条记录。insertLocked 用二分查找定位,同分时把 ID 小的排在前面。Reset 同时清空切片和分数索引,防止下一次 Add 读到旧赛季分数。

八、测试:并列、更新与重置

测试覆盖密集名次的并列关系、更新后位置移动、TopN 边界,以及重置后分数索引是否清空。

// file: ch10/social/rank_test.go
// tier: L1
// verify: go test ./ch10/social/

package social

import (
	"reflect"
	"testing"
)

func rankIDs(entries []RankEntry) []uint64 {
	out := make([]uint64, len(entries))
	for i, e := range entries {
		out[i] = e.PlayerID
	}
	return out
}

func TestRankTiesUseDenseRanking(t *testing.T) {
	b := NewRankBoard("s1")
	b.Update(1, 100)
	b.Update(2, 100)
	b.Update(3, 90)
	b.Update(4, 90)
	b.Update(5, 80)

	top := b.Top(10)
	if got, want := rankIDs(top), []uint64{1, 2, 3, 4, 5}; !reflect.DeepEqual(got, want) {
		t.Fatalf("order = %v, want %v", got, want)
	}
	wantRanks := []int{1, 1, 2, 2, 3}
	for i, want := range wantRanks {
		if top[i].Rank != want {
			t.Fatalf("rank[%d] = %d, want %d", i, top[i].Rank, want)
		}
	}
	if rank, ok := b.Rank(3); !ok || rank != 2 {
		t.Fatalf("rank(3) = %d, %v", rank, ok)
	}
	if _, ok := b.Rank(99); ok {
		t.Fatal("unknown player should not have a rank")
	}
}

func TestRankUpdateMovesEntry(t *testing.T) {
	b := NewRankBoard("s1")
	b.Update(1, 100)
	b.Update(2, 90)
	b.Update(3, 80)
	b.Update(3, 120)

	if score, _ := b.Score(3); score != 120 {
		t.Fatalf("score(3) = %d, want 120", score)
	}
	if got, want := rankIDs(b.Top(3)), []uint64{3, 1, 2}; !reflect.DeepEqual(got, want) {
		t.Fatalf("order after update = %v, want %v", got, want)
	}
	if rank, _ := b.Rank(1); rank != 2 {
		t.Fatalf("rank(1) = %d, want 2", rank)
	}
	if b.Len() != 3 {
		t.Fatalf("len = %d, want 3", b.Len())
	}
	if got := b.Add(2, 50); got != 140 {
		t.Fatalf("add(2, 50) = %d, want 140", got)
	}
	if got := b.Top(1)[0].PlayerID; got != 2 {
		t.Fatalf("top player = %d, want 2", got)
	}
}

func TestRankTopBounds(t *testing.T) {
	b := NewRankBoard("s1")
	b.Update(1, 10)
	b.Update(2, 20)
	if len(b.Top(0)) != 0 || b.Top(-1) != nil {
		t.Fatal("non-positive n should return nil")
	}
	if got := len(b.Top(100)); got != 2 {
		t.Fatalf("top(100) len = %d, want 2", got)
	}
}

func TestRankResetClearsSeasonData(t *testing.T) {
	b := NewRankBoard("s1")
	b.Update(1, 100)
	b.Reset("s2")
	if b.Season() != "s2" {
		t.Fatalf("season = %q, want s2", b.Season())
	}
	if b.Len() != 0 || len(b.Top(10)) != 0 {
		t.Fatal("reset left entries behind")
	}
	if _, ok := b.Score(1); ok {
		t.Fatal("reset left score index behind")
	}
	if got := b.Add(1, 5); got != 5 {
		t.Fatalf("first add after reset = %d, want 5", got)
	}
}

并列测试不仅检查顺序,还逐项检查名次数组;只比较第一名,无法发现“并列跳号”。重置测试在清空后再调用一次 Add,确认新赛季从零开始,而不是继承旧值。

九、运行测试

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

小结

互动接口先过关系和频率,再写业务记录,并用幂等键防重。排行榜按范围拆数据源,写入时维护顺序,读取时只取 TopN;并列使用稳定的密集名次和第二关键字;赛季重置要同时清分数、名次和缓存。规模上来后,优先把排序移出读路径,而不是给全量排序加机器。

常见错误与边界

  • 每次请求都全量排序,读放大随玩家数线性增长。
  • 并列名次使用竞赛名次却按密集名次展示,出现跳号或重号。
  • 同分没有第二关键字,分页顺序不稳定,重复或漏人。
  • 赛季重置只清分数表,名次缓存和“我的排名”继续返回旧值。
  • 好友榜单独维护全量副本,好友删除后仍能看到对方。
  • 公会榜不处理成员退会,分数继续挂在旧公会。
  • 频率限制放在业务写入之后,失败也扣次数,玩家被误伤。
  • 只按连接或 IP 限速,重连和小号可以绕过。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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