《游戏服务端架构·核心系统与契约》8.1 好友、黑名单与关系一致性

《裂隙战场》《群岛纪元》《泡泡工坊》对好友、关注和黑名单的需求并不相同。本节把关系链还原为有向边加不变量,比较双写事务、异步回写和读时合成三种方案,说明 A 加 B 时另一侧如何更新、失败时能否不对称,并给出可运行的有向关系存储与账号删除清理边界。

关系链不是一张好友表

《裂隙战场》一局十个人,玩家加好友是为了下一局继续组队;《群岛纪元》的社交单位是联盟,个人好友更多是私聊入口;《泡泡工坊》的房间十分钟就散,好友功能近乎装饰。三款产品都需要回答“谁是朋友、谁被拉黑”,但需要的强度不同。

最早的实现把好友存成两行记录:A→B 和 B→A。上线后发现两类脏数据:写第二行时进程重启,留下单向好友;拉黑只改了 A→B 的过滤,B 仍能看到 A 在线并继续发邀请。前者是事务边界问题,后者是把“存储方向”和“可见性语义”混为一谈。

本节的主张是:存储保持有向,语义在查询时收敛。关注天然单向;好友是两条有向边组成的不变量;黑名单是一条有向边,但任何方向命中都让整对关系不可见。这样既不用为每种关系建一张表,也能把“允许不对称”限制在关注上。

三种关系,三种语义

关系存储方向是否对称典型入口撤销后
关注A→B 一条否看过资料、战队主页立即消失,不影响好友
好友A→B 与 B→A是,必须成对请求-接受、GM 补发两侧同时删除
黑名单拉黑者→被拉黑者查询按双向生效举报、屏蔽解除后不恢复好友

把黑名单也存成有向边,是为了留下“谁拉黑了谁”这一事实;但 Effective 与 FriendsOf 查询时会检查两个方向。若只检查拉黑者方向,被拉黑者仍能向拉黑者发请求,等于把骚扰入口留了下来。

好友的对称性不能靠读时合成。读时把 A→B 与 B→A 求交集,看似省了一次写,但删除路径、计数、推送都要重复判断,任何一处漏掉就会出现“列表里有、点进去不是好友”。我们选择写入时保证成对,读时只做一次校验。

双向一致性的三个方案

好友写入有三种常见做法,差别不在性能,而在失败时留下什么。

方案写入方式失败时代价适用
同聚合双写以排序后的 pair 为键,一个事务写两侧整体回滚,无中间态需要 pair 级串行化单服、单分片
接受方异步回写A 先写,消息让 B 补写可能长期半边补偿任务与去重跨服、弱一致
读时合成只存请求,读时判断无脏写每次查询两次读,删除难关系极轻

《裂隙战场》和《泡泡工坊》选同聚合双写:好友数量小、关系都在一个逻辑分片内,pair 键让并发加删自然串行。《群岛纪元》的跨服好友走异步回写,但加了一条硬规则:接受方的写入必须幂等,并以请求 ID 去重;在回写完成前,双方列表都显示“处理中”,不显示“已成为好友”。

失败时能不能不对称?关注可以,好友不可以,黑名单必须立即生效。好友的中间态只能叫“请求中”,不能叫“好友”;一旦对玩家展示为好友,后续所有依赖(组队、赠礼、私聊)都会假设双向存在。

状态机与优先级

none --follow--> following
none --request--> pending --accept--> friend
any --block--> blocked
blocked --unblock--> none        # 不恢复 friend/following

优先级固定为 block > friend > follow。Effective 先看两个方向有没有 block,再看是否互为 friend,最后才看单向 follow。顺序不能反:先判 friend 会让“拉黑后仍是好友”的脏数据继续可见。

待处理请求单独存放,不占用关系边。这样过期请求可以批量清理,也不会被 FriendsOf 误算。接受请求时先删请求再写两条边;两步在同一个锁或事务内完成。

可运行片段:有向边与成对好友

下面的 internal/relationships 只做关系存储与查询,不掺在线状态、推送和跨服同步。它把好友成对写入、黑名单双向命中、请求生命周期收在一个包里;账号删除的 Purge 与单向关注的撤销在同包的 cleanup.go,文章不重复展开。

// file: internal/relationships/relationships.go
// tier: L1
// verify: go test ./internal/relationships/
package relationships

import (
	"errors"
	"sort"
)

// Kind is the effective relation from one role to another. Blocked always wins,
// even when the opposite direction still stores a follow edge.
type Kind int

const (
	KindNone Kind = iota
	KindFollow
	KindFriend
	KindBlocked
)

func (k Kind) String() string {
	switch k {
	case KindFollow:
		return "follow"
	case KindFriend:
		return "friend"
	case KindBlocked:
		return "blocked"
	default:
		return "none"
	}
}

var (
	ErrEmptyRole = errors.New("relationships: empty role")
	ErrSelf      = errors.New("relationships: self relation")
	ErrBlocked   = errors.New("relationships: blocked pair")
	ErrNoRequest = errors.New("relationships: no pending request")
	ErrNotFriend = errors.New("relationships: not friends")
)

// Store keeps directed edges. Friendship is two directed KindFriend edges and
// is always written as a pair; a block is a directed edge that wins every
// query regardless of direction.
type Store struct {
	edges    map[string]map[string]Kind
	requests map[string]map[string]struct{}
}

// New returns an empty store.
func New() *Store {
	return &Store{
		edges:    make(map[string]map[string]Kind),
		requests: make(map[string]map[string]struct{}),
	}
}

func (s *Store) valid(a, b string) error {
	if a == "" || b == "" {
		return ErrEmptyRole
	}
	if a == b {
		return ErrSelf
	}
	return nil
}

func (s *Store) blocked(a, b string) bool {
	return s.edges[a][b] == KindBlocked || s.edges[b][a] == KindBlocked
}

func (s *Store) set(from, to string, k Kind) {
	m := s.edges[from]
	if m == nil {
		m = make(map[string]Kind)
		s.edges[from] = m
	}
	m[to] = k
}

func (s *Store) del(from, to string) {
	if s.edges[from] == nil {
		return
	}
	delete(s.edges[from], to)
	if len(s.edges[from]) == 0 {
		delete(s.edges, from)
	}
}

func (s *Store) dropRequest(from, to string) {
	delete(s.requests[from], to)
	if len(s.requests[from]) == 0 {
		delete(s.requests, from)
	}
}

// Follow records a one-way follow edge. Any block on either side wins.
func (s *Store) Follow(from, to string) error {
	if err := s.valid(from, to); err != nil {
		return err
	}
	if s.blocked(from, to) {
		return ErrBlocked
	}
	if s.edges[from][to] == KindFriend {
		return nil
	}
	s.set(from, to, KindFollow)
	return nil
}

// RequestFriend stores a pending request. The pair is not friends yet.
func (s *Store) RequestFriend(from, to string) error {
	if err := s.valid(from, to); err != nil {
		return err
	}
	if s.blocked(from, to) {
		return ErrBlocked
	}
	if s.edges[from][to] == KindFriend && s.edges[to][from] == KindFriend {
		return nil
	}
	m := s.requests[from]
	if m == nil {
		m = make(map[string]struct{})
		s.requests[from] = m
	}
	m[to] = struct{}{}
	return nil
}

// AcceptFriend turns a pending request into two friend edges. Both edges are
// written in one in-memory step, so no state exposes a one-sided friendship.
func (s *Store) AcceptFriend(from, to string) error {
	if err := s.valid(from, to); err != nil {
		return err
	}
	if s.blocked(from, to) {
		return ErrBlocked
	}
	if _, ok := s.requests[to][from]; !ok {
		return ErrNoRequest
	}
	s.dropRequest(to, from)
	s.set(from, to, KindFriend)
	s.set(to, from, KindFriend)
	return nil
}

// AddFriend is the direct two-sided path used by GM tools and migrations.
func (s *Store) AddFriend(a, b string) error {
	if err := s.valid(a, b); err != nil {
		return err
	}
	if s.blocked(a, b) {
		return ErrBlocked
	}
	s.set(a, b, KindFriend)
	s.set(b, a, KindFriend)
	return nil
}

// Block records a directed block and removes friendship in both directions.
// Existing follows are removed too; they do not become visible again on unblock.
func (s *Store) Block(blocker, target string) error {
	if err := s.valid(blocker, target); err != nil {
		return err
	}
	if s.edges[blocker][target] != KindBlocked {
		s.del(blocker, target)
	}
	if s.edges[target][blocker] != KindBlocked {
		s.del(target, blocker)
	}
	s.dropRequest(blocker, target)
	s.dropRequest(target, blocker)
	s.set(blocker, target, KindBlocked)
	return nil
}

// Unblock removes the blocker's block edge. A friendship removed by Block is
// not restored; callers must re-add it explicitly.
func (s *Store) Unblock(blocker, target string) error {
	if err := s.valid(blocker, target); err != nil {
		return err
	}
	if s.edges[blocker][target] == KindBlocked {
		s.del(blocker, target)
	}
	return nil
}

// Effective applies precedence: block, then mutual friend, then follow.
func (s *Store) Effective(from, to string) Kind {
	if s.blocked(from, to) {
		return KindBlocked
	}
	if s.edges[from][to] == KindFriend && s.edges[to][from] == KindFriend {
		return KindFriend
	}
	if s.edges[from][to] == KindFollow {
		return KindFollow
	}
	return KindNone
}

// FriendsOf returns the sorted mutual friends of role.
func (s *Store) FriendsOf(role string) []string {
	out := make([]string, 0, 4)
	for other, k := range s.edges[role] {
		if k == KindFriend && s.edges[other][role] == KindFriend {
			out = append(out, other)
		}
	}
	sort.Strings(out)
	return out
}

几个取舍:Block 会删除两个方向的好友与关注,但保留对方已有的 block,避免“我再拉黑一次就把对方的拉黑解除了”。Unblock 只删自己那条边,不恢复好友;恢复必须重新走请求。Follow 遇到已有好友时直接返回,不把好友降级成关注。RequestFriend 对已是好友的 pair 是幂等的,方便客户端重试。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/relationships
$ go vet ./internal/relationships/...
$ go test -count=1 ./internal/relationships/
ok  	gsarchlab/internal/relationships

本机 go test 退出码为 0,5 个表驱动测试通过;gofmt -l 与 go vet 均无输出。

失败模式与兜底

失败表现兜底
第二行写失败单向好友,列表不一致pair 事务或幂等补写,定期扫描不变量
拉黑只过滤单向被拉黑者仍可邀请查询双向判断,入口统一走 Effective
请求过期未清理大量 pending 占内存TTL 清理,接受时校验请求存在
删除角色只删正向边他人列表残留Purge 双向扫描并返回受影响 peer 数
跨服回写重复重复好友、重复通知请求 ID 去重,写入幂等
解除拉黑即恢复玩家被动恢复关系Unblock 不恢复,重新请求

账号删除与跨服边界

角色删除不是删一行:Purge 要清掉该角色出发的边、指向它的边,以及两个方向的待处理请求,并返回受影响的 peer 数量,用于让好友列表缓存失效。返回值不是“删了多少行”,而是“多少个玩家的视图变了”,这决定了要推多少条失效消息。

跨服好友不共享一张全局关系表。每个角色以所属服为权威,好友关系在双方所在服各存一份投影,由关系服务按角色 ID 路由事件。投影允许短暂过期,但“是否好友”的最终判定回到权威服;黑名单则在本地立即生效,宁可多屏蔽,也不让骚扰穿透。

合服或转服时,关系迁移以角色 ID 为键重放事件,不直接拷贝邻接表;邻接表里可能含有已删除角色和目标服不存在的 ID,重放时逐条校验并记录丢弃数。

常见错误

第一,把好友存成一张无方向的边表。查询要同时匹配两个方向,索引难用,删除时还要防重复。

第二,让黑名单只影响列表展示。私聊、组队邀请、空间点赞都必须经过同一个可见性判断,否则拉黑只是眼不见为净。

第三,用异步回写却对外宣称即时好友。中间态必须显式,客户端要能显示“请求已发送”或“处理中”。

第四,删除角色时只删主键。关系是双向的,清理必须覆盖入边、出边和待处理请求。

第五,把跨服关系做成分布式事务。关系不是资产,短暂不一致可以接受;用权威服判定加幂等投影,比两阶段提交便宜得多。

小结

关系链的核心不是表结构,而是三条规则:关注允许单向,好友必须成对,黑名单双向生效。存储保持有向,语义在查询和写入边界收敛;失败时只允许关注不对称,好友的中间态只能叫请求。

好友回答“谁和谁在一起”。下一节把尺度放大到几十人:公会如何定义角色与权限,仓库在多人同时取物时如何不超卖,解散时资产又该怎么清算。

阅读导航:上一节:7.3 每日重置、时区与赛季 · 下一节:8.2 公会、权限与仓库 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查