一、互动功能的共同骨架
赠送体力、点赞、组队邀请看起来是三件事,服务端骨架却相同:校验关系、校验频率、写一次业务记录、返回剩余次数。把这三步拆开写在每个接口里,最后一定有人漏掉关系校验或频率校验。
建议统一成“动作 + 目标 + 幂等键”的请求模型。动作决定消耗和奖励,目标决定关系检查,幂等键防止客户端重试造成重复赠送。所有互动都先过关系层,再过风控层,最后落业务表。
二、频率限制与防刷
| 维度 | 限制方式 | 作用 | 注意 |
|---|---|---|---|
| 单玩家 | 令牌桶或滑动窗口 | 限制整体操作速度 | 窗口要按服务器时间 |
| 单目标 | 每目标每分钟 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 限速,重连和小号可以绕过。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。