关系链不是一张好友表
《裂隙战场》一局十个人,玩家加好友是为了下一局继续组队;《群岛纪元》的社交单位是联盟,个人好友更多是私聊入口;《泡泡工坊》的房间十分钟就散,好友功能近乎装饰。三款产品都需要回答“谁是朋友、谁被拉黑”,但需要的强度不同。
最早的实现把好友存成两行记录: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 公会、权限与仓库 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。