《游戏服务端编程实践》10.2.1 好友关系与黑名单

从单向申请与双向好友的模型差异出发,拆解同意、删除、数量上限和黑名单优先级,说明跨服好友如何用归一化 pair key 保证成对写入、把持久关系与在线状态分离,并给出可测试的 FriendStore 实现,以及重复申请、残留申请、拉黑后私聊与组队拦截的测试。

一、先定关系方向,再谈功能

好友系统最容易犯的错,是把“我加了你”和“我们是好友”混成一条记录。前者是单向申请,后者是双向关系;前者可以重复、可以过期,后者必须成对存在。数据模型没分开,后面每个功能都要打补丁。

一条关系至少要有拥有者、目标玩家、关系类型、状态和更新时间。双向好友建议再存一个归一化的 pair key(小 ID 在前),用唯一索引保证同一对玩家只有一条有效关系,避免 A 侧删除、B 侧残留。

二、单向关注与双向好友

模型存储方式写入读取适用
单向关注每个关注者一行只写自己查粉丝要反向索引内容社区、主播
双向好友一对玩家一条关系同意时写两边两边都能直接查组队、私聊
混合模型关注表加好友表两套状态需要合并去重社交平台

单向关注适合“我关心你,但你不一定关心我”的场景,成本和隐私边界都更清晰。双向好友适合需要互相确认的玩法:组队、赠送体力、私聊。不要把关注直接当好友用,否则拉黑、删除和数量上限都会变得含糊。

三、申请、同意与删除是一条状态机

申请只有 pending 一个有效状态,重复申请应返回明确错误,而不是悄悄刷新时间。同意时要在同一把锁或同一个事务里做三件事:校验申请仍在、检查双方上限、写入两条好友边。任何一步分开,都可能出现“申请没了、好友只写了一半”。

删除要同时清掉双向关系,以及两个方向上残留的申请。很多实现只删好友边,留下一条旧的 pending;玩家重新添加时被判定为重复申请,或者绕过对方确认直接恢复关系。

数量上限要在申请和同意两个时机都检查。申请时不检查,待处理列表会被刷爆;同意时不检查,两个玩家同时接受不同申请就可能超限。

四、黑名单的优先级高于好友

检查顺序条件结果
1任意一方拉黑对方拒绝私聊、组队和申请
2双方是好友允许私聊、组队
3只有单向申请只允许查看申请,不允许私聊
4无关系拒绝,并提示先加好友

黑名单不是“从好友列表里隐藏”,而是关系上的否决位。拉黑时应当删除好友关系和待处理申请,之后的消息、组队邀请、好友申请都先查黑名单。只在前端过滤列表,服务端仍然能收到私聊,这是最常见的漏拦。

黑名单的方向也要明确。很多游戏把拉黑做成双向生效:A 拉黑 B 后,A 不再收到 B 的消息,B 也不能给 A 发消息。若产品要求“我还能看对方”,应在读取侧做例外,而不是让写入侧绕过拦截。

五、跨服好友:关系与在线状态分开存

好友关系是持久数据,应放在全局社交服务或共享存储,按账号或全局角色 ID 建索引;在线状态是短生命周期数据,放在网关会话目录里,用心跳和 TTL 维护。两者混在一张表,跨服查询会不断写在线字段,缓存也很难失效。

查询在线好友时,先取关系列表,再批量问 Presence 服务,最后按稳定顺序返回。不要在好友行里缓存 online=true,进程重启或网络分区后,这个字段会长期错误。关系写入则以 pair key 串行化,保证两台服务器同时同意同一申请时只有一次生效。

六、失败场景:关系只写一边

假设同意申请时先写 A 的好友列表,再写 B 的列表,第二步因为超时失败。A 看到 B 在好友里,B 看不到 A;A 发消息时服务端按自己的列表放行,B 的客户端却找不到会话。更糟的是重试逻辑又把申请删了,双方既不是好友,也无法重新申请。

正确做法是把“删除申请、写两条边”放在同一个原子操作里,失败就整体回滚。跨服场景还要加幂等键,重复同意返回同一结果,而不是第二次报“申请不存在”。测试要专门覆盖重复申请、重复同意和删除后的重新申请。

七、Go 实现:FriendStore

下面的实现把好友、申请和黑名单分成三张表,所有变更在同一把互斥锁内完成。Presence 只负责在线判定,关系数据不依赖它。

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

package social

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

var (
	ErrSelfRelation   = errors.New("social: cannot relate to self")
	ErrAlreadyFriends = errors.New("social: already friends")
	ErrDuplicateApply = errors.New("social: duplicate friend apply")
	ErrApplyNotFound  = errors.New("social: friend apply not found")
	ErrNotFriends     = errors.New("social: not friends")
	ErrFriendLimit    = errors.New("social: friend limit reached")
	ErrBlocked        = errors.New("social: relation blocked")
)

// Presence 由网关或会话服务实现,关系数据仍存放在 FriendStore。
type Presence interface {
	Online(playerID uint64) bool
}

// FriendStore 保存双向好友、单向申请和黑名单。
type FriendStore struct {
	mu      sync.Mutex
	limit   int
	friends map[uint64]map[uint64]struct{}
	applies map[uint64]map[uint64]struct{} // 接收者 -> 申请者
	blocked map[uint64]map[uint64]struct{} // 拉黑者 -> 被拉黑者
}

func NewFriendStore(limit int) *FriendStore {
	if limit <= 0 {
		limit = 1
	}
	return &FriendStore{
		limit:   limit,
		friends: make(map[uint64]map[uint64]struct{}),
		applies: make(map[uint64]map[uint64]struct{}),
		blocked: make(map[uint64]map[uint64]struct{}),
	}
}

// Apply 发起申请;重复申请返回错误而不是刷新时间戳。
func (s *FriendStore) Apply(from, to uint64) error {
	if from == to {
		return ErrSelfRelation
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.blockedLocked(from, to) {
		return ErrBlocked
	}
	if _, ok := s.friends[from][to]; ok {
		return ErrAlreadyFriends
	}
	if _, ok := s.applies[to][from]; ok {
		return ErrDuplicateApply
	}
	if len(s.friends[from]) >= s.limit || len(s.friends[to]) >= s.limit {
		return ErrFriendLimit
	}
	if s.applies[to] == nil {
		s.applies[to] = make(map[uint64]struct{})
	}
	s.applies[to][from] = struct{}{}
	return nil
}

// Accept 在同一把锁内建立双向关系,避免只写一边。
func (s *FriendStore) Accept(receiver, sender uint64) error {
	if receiver == sender {
		return ErrSelfRelation
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.applies[receiver][sender]; !ok {
		return ErrApplyNotFound
	}
	if s.blockedLocked(receiver, sender) {
		return ErrBlocked
	}
	if len(s.friends[receiver]) >= s.limit || len(s.friends[sender]) >= s.limit {
		return ErrFriendLimit
	}
	delete(s.applies[receiver], sender)
	s.linkLocked(receiver, sender)
	return nil
}

// Delete 同时删除双向关系和两个方向上的残留申请。
func (s *FriendStore) Delete(a, b uint64) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.friends[a][b]; !ok {
		return ErrNotFriends
	}
	s.unlinkLocked(a, b)
	delete(s.applies[a], b)
	delete(s.applies[b], a)
	return nil
}

// Block 丢弃好友关系和待处理申请,再写入单向黑名单记录。
func (s *FriendStore) Block(blocker, target uint64) error {
	if blocker == target {
		return ErrSelfRelation
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.blocked[blocker] == nil {
		s.blocked[blocker] = make(map[uint64]struct{})
	}
	s.blocked[blocker][target] = struct{}{}
	s.unlinkLocked(blocker, target)
	delete(s.applies[blocker], target)
	delete(s.applies[target], blocker)
	return nil
}

// SendMessage 先看黑名单,再看好友关系。
func (s *FriendStore) SendMessage(from, to uint64) error {
	if from == to {
		return ErrSelfRelation
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.blockedLocked(from, to) {
		return ErrBlocked
	}
	if _, ok := s.friends[from][to]; !ok {
		return ErrNotFriends
	}
	return nil
}

func (s *FriendStore) Friends(id uint64) []uint64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]uint64, 0, len(s.friends[id]))
	for fid := range s.friends[id] {
		out = append(out, fid)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}

// OnlineFriends 只把在线判定委托给 Presence,不在关系存储里缓存在线状态。
func (s *FriendStore) OnlineFriends(id uint64, p Presence) []uint64 {
	friends := s.Friends(id)
	out := friends[:0]
	for _, fid := range friends {
		if p.Online(fid) {
			out = append(out, fid)
		}
	}
	return out
}

// blockedLocked 让黑名单双向生效:任意一方拉黑,双方都不能私聊或组队。
func (s *FriendStore) blockedLocked(a, b uint64) bool {
	_, ab := s.blocked[a][b]
	_, ba := s.blocked[b][a]
	return ab || ba
}

func (s *FriendStore) linkLocked(a, b uint64) {
	if s.friends[a] == nil {
		s.friends[a] = make(map[uint64]struct{})
	}
	if s.friends[b] == nil {
		s.friends[b] = make(map[uint64]struct{})
	}
	s.friends[a][b] = struct{}{}
	s.friends[b][a] = struct{}{}
}

func (s *FriendStore) unlinkLocked(a, b uint64) {
	delete(s.friends[a], b)
	delete(s.friends[b], a)
}

Apply 在写入前检查黑名单、既有好友、重复申请和双方上限;Accept 再检查一次上限,然后一次性写入双向边。Block 会先删关系和申请,再记录黑名单,因此后续消息一定先撞上 ErrBlocked。

八、测试:重复申请、残留申请与黑名单

测试覆盖三个最容易回归的点:重复申请必须失败;删除好友后两个方向的残留申请都要消失;拉黑后好友关系和私聊同时失效。

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

package social

import (
	"errors"
	"reflect"
	"testing"
)

func TestFriendApplyAndAccept(t *testing.T) {
	s := NewFriendStore(50)
	if err := s.Apply(1, 2); err != nil {
		t.Fatal(err)
	}
	if err := s.Apply(1, 2); !errors.Is(err, ErrDuplicateApply) {
		t.Fatalf("duplicate apply = %v", err)
	}
	if err := s.Accept(1, 2); !errors.Is(err, ErrApplyNotFound) {
		t.Fatalf("wrong receiver = %v", err)
	}
	if err := s.Accept(2, 1); err != nil {
		t.Fatal(err)
	}
	if got, want := s.Friends(1), []uint64{2}; !reflect.DeepEqual(got, want) {
		t.Fatalf("friends(1) = %v, want %v", got, want)
	}
	if got, want := s.Friends(2), []uint64{1}; !reflect.DeepEqual(got, want) {
		t.Fatalf("friends(2) = %v, want %v", got, want)
	}
	if err := s.SendMessage(1, 2); err != nil {
		t.Fatalf("message after accept: %v", err)
	}
}

func TestFriendDeleteClearsBothPending(t *testing.T) {
	s := NewFriendStore(50)
	if err := s.Apply(1, 2); err != nil {
		t.Fatal(err)
	}
	if err := s.Apply(2, 1); err != nil {
		t.Fatal(err)
	}
	if err := s.Accept(2, 1); err != nil {
		t.Fatal(err)
	}
	if err := s.Delete(1, 2); err != nil {
		t.Fatal(err)
	}
	if len(s.Friends(1)) != 0 || len(s.Friends(2)) != 0 {
		t.Fatal("delete left a one-sided friendship")
	}
	if err := s.SendMessage(2, 1); !errors.Is(err, ErrNotFriends) {
		t.Fatalf("message after delete = %v", err)
	}
	if err := s.Apply(2, 1); err != nil {
		t.Fatalf("stale pending after delete: %v", err)
	}
}

func TestBlockOverridesFriendship(t *testing.T) {
	s := NewFriendStore(50)
	if err := s.Apply(1, 2); err != nil {
		t.Fatal(err)
	}
	if err := s.Accept(2, 1); err != nil {
		t.Fatal(err)
	}
	if err := s.Block(2, 1); err != nil {
		t.Fatal(err)
	}
	if len(s.Friends(1)) != 0 || len(s.Friends(2)) != 0 {
		t.Fatal("block did not remove friendship")
	}
	if err := s.SendMessage(1, 2); !errors.Is(err, ErrBlocked) {
		t.Fatalf("blocked sender = %v", err)
	}
	if err := s.SendMessage(2, 1); !errors.Is(err, ErrBlocked) {
		t.Fatalf("blocker message = %v", err)
	}
	if err := s.Apply(1, 2); !errors.Is(err, ErrBlocked) {
		t.Fatalf("apply to blocker = %v", err)
	}
}

删除测试里先让双方互相申请,再接受其中一条,另一条会留在对端。Delete 之后重新申请必须成功;如果实现漏删残留申请,这一步会返回 ErrDuplicateApply。

九、运行测试

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

gofmt -l 没有输出表示格式正确;go vet 没有输出表示静态检查通过。

小结

好友系统要把单向申请和双向关系分开存,用 pair key 保证成对写入;删除时同时清理关系和残留申请;黑名单是最高优先级的关系否决位,必须由服务端统一拦截。跨服场景把持久关系和短生命周期在线状态拆开,查询时再组合。

常见错误与边界

  • 好友关系只写一边,读取时依赖某一侧的缓存,跨服后直接不一致。
  • 删除好友不删申请,重新添加被误判为重复申请,或者绕过确认恢复关系。
  • 黑名单只过滤列表,私聊和组队接口仍按好友关系放行。
  • 申请时不校验数量上限,待处理列表被脚本刷满。
  • 同意时不在事务内完成,进程崩溃后留下半条关系。
  • 把在线状态写进好友表,重启或分区后长期显示错误。
  • 同一对玩家没有唯一键,双向写入产生两条重复关系。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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