11.3 好友关系与关系一致性
好友关系看起来只是一个列表,实则是 MMO 里最容易出现数据不一致的一类状态。难点在于它是双向的:A 是 B 的好友,B 就必然是 A 的好友。任何一次写入如果只写了一半,就会留下一只脚踩空的「半边关系」——A 的好友列表里有 B,B 的列表里却没有 A。这种脏数据不会立刻报错,只会在某天玩家投诉「我明明加了他好友」时才暴露。
第二个难点是幂等与顺序。加好友是客户端发起的操作,会重发、会乱序到达,甚至同一个人连点两次「申请」。卷一 11.1 请求幂等窗口 和 8.2 消息序号、去重与顺序保证 里的两套机制在这里必须复用:同一操作重复到达只能生效一次,否则「同意」会覆盖掉后来的「删除」。
本节把好友关系建成一张有向图,用「双向一致不变量」把正确性写死,用一个全局 opID 的幂等窗口挡住重发,最后给出半边检测与补偿的实现。
关系模型:有向边与双向不变量
把关系图建成邻接表:edges[a][b] 表示 a 对 b 的关系状态。四种状态:RelRequested(a 已向 b 申请)、RelFriend(双向好友)、RelBlocked(a 拉黑了 b),以及用「键不存在」表示的 RelNone。用有向边建模的好处是「申请」和「拉黑」天然不对称——a 申请 b 不代表 b 申请 a,a 拉黑 b 也不代表 b 拉黑 a——而「好友」则是两条边同时为 RelFriend。
不变量只有一条:任何一条 RelFriend 边都必须双向存在。 所有写操作都在这条约束下设计:同意申请时同时写两条边,删除好友时同时删两条边,拉黑时把正向设成 RelBlocked、反向清掉。整个图共用一把锁,这一对写操作要么都发生、要么都不发生。下面这段是 L1,go test 可以直接跑:
// file: internal/chat/friend.go
// tier: L1
// verify: go test ./internal/chat/
package chat
import (
"errors"
"sync"
)
// Relation 是好友关系图里一条有向边的状态。
type Relation uint8
const (
RelNone Relation = iota
RelRequested // From 已向 To 发起申请,等待对方回应
RelFriend // 双向好友
RelBlocked // From 拉黑了 To
)
func (r Relation) String() string {
switch r {
case RelNone:
return "none"
case RelRequested:
return "requested"
case RelFriend:
return "friend"
case RelBlocked:
return "blocked"
}
return "unknown"
}
// FriendService 维护好友关系图。好友是「双向边」,因此所有写操作
// 都必须在同一临界区内成对完成,绝不能只写一半。
type FriendService struct {
mu sync.Mutex
edges map[PlayerID]map[PlayerID]Relation
applied map[uint64]FriendResult // 幂等窗口:opID -> 首次结果
}
func NewFriendService() *FriendService {
return &FriendService{
edges: map[PlayerID]map[PlayerID]Relation{},
applied: map[uint64]FriendResult{},
}
}
func (s *FriendService) out(a PlayerID) map[PlayerID]Relation {
m := s.edges[a]
if m == nil {
m = map[PlayerID]Relation{}
s.edges[a] = m
}
return m
}
func (s *FriendService) get(a, b PlayerID) Relation { return s.edges[a][b] }
func (s *FriendService) set(a, b PlayerID, r Relation) {
if r == RelNone {
if m := s.edges[a]; m != nil {
delete(m, b)
}
return
}
s.out(a)[b] = r
}
// FriendOpKind 是关系变更的种类。
type FriendOpKind uint8
const (
OpRequest FriendOpKind = iota
OpAccept
OpRemove
OpBlock
)
// FriendOp 是一次关系变更请求。ID 是幂等号,客户端重发时复用。
type FriendOp struct {
ID uint64
Kind FriendOpKind
From PlayerID
To PlayerID
}
// FriendResult 是变更结果。Applied=false 表示命中幂等窗口,本次未改动关系图。
type FriendResult struct {
Applied bool
State Relation // 变更后 From 对 To 的关系
}
var (
ErrSelfOp = errors.New("friend: cannot operate on self")
ErrNoRequest = errors.New("friend: no pending request to accept")
ErrBlockedByPeer = errors.New("friend: the other side has blocked you")
ErrBadOp = errors.New("friend: unknown operation")
)
// Apply 执行一次关系变更。同一个 opID 重复到达时直接返回首次结果,
// 与卷一 11.1 的幂等窗口同一思路:图是共享状态,重复写代价远高于去重。
func (s *FriendService) Apply(op FriendOp) (FriendResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
if prev, ok := s.applied[op.ID]; ok {
prev.Applied = false
return prev, nil
}
if op.From == op.To {
return FriendResult{}, ErrSelfOp
}
var err error
switch op.Kind {
case OpRequest:
err = s.request(op.From, op.To)
case OpAccept:
err = s.accept(op.From, op.To)
case OpRemove:
s.set(op.From, op.To, RelNone)
s.set(op.To, op.From, RelNone)
case OpBlock:
s.set(op.From, op.To, RelBlocked)
s.set(op.To, op.From, RelNone)
default:
return FriendResult{}, ErrBadOp
}
if err != nil {
return FriendResult{}, err
}
res := FriendResult{Applied: true, State: s.get(op.From, op.To)}
s.applied[op.ID] = res
return res, nil
}
func (s *FriendService) request(a, b PlayerID) error {
if s.get(b, a) == RelBlocked {
return ErrBlockedByPeer
}
if s.get(a, b) == RelFriend {
return nil // 已是好友,重复申请是幂等的
}
if s.get(b, a) == RelRequested {
// 双方互相申请,直接成好友:省掉一次「同意」往返。
s.set(a, b, RelFriend)
s.set(b, a, RelFriend)
return nil
}
s.set(a, b, RelRequested)
return nil
}
func (s *FriendService) accept(a, b PlayerID) error {
if s.get(b, a) != RelRequested {
return ErrNoRequest
}
s.set(a, b, RelFriend)
s.set(b, a, RelFriend)
return nil
}
// Friends 返回某玩家的好友列表(升序)。
func (s *FriendService) Friends(a PlayerID) []PlayerID {
s.mu.Lock()
defer s.mu.Unlock()
var out []PlayerID
for b, r := range s.edges[a] {
if r == RelFriend {
out = append(out, b)
}
}
sortIDs(out)
return out
}
注意 request 里那条「双方互相申请直接成好友」的分支:它省掉一次「同意」往返,但前提是幂等窗口能识别「同一个人重复申请」和「两个人各申请一次」的区别——前者靠 opID 挡住,后者靠边状态识别。
幂等与顺序:重复申请、重复同意
好友操作由客户端发起,就会重发。卷一 11.1 讲过的三条重发路径——客户端超时重发、网关重试、断线重连补发——在这里一条不少。如果不管,一次「同意」重发两次,就可能把已经删除的关系又写成好友。
处理方式和卷一 11.1 的幂等窗口同源:每个操作带一个客户端生成的 opID,服务端把「见过的 opID → 首次结果」记在窗口里,重复到达直接返回首次结果、不改图。区别只在于好友图是共享状态,而卷一的幂等窗口是每连接的——所以 opID 必须全局唯一(例如 玩家ID << 32 | 本地自增),不能只靠连接内自增。这和卷一 8.2 的顺序保证也呼应:序号解决「同一连接内的帧次序」,opID 解决「跨连接、跨重连的操作身份」,两者配合才能保证「加好友」不会被记两次。下面是幂等分支的 snippet:
// snippet: internal/chat/friend.go(opID 幂等窗口,节选)
func (s *FriendService) Apply(op FriendOp) (FriendResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
// 幂等窗口:命中就直接返回首次结果,不改动关系图。
if prev, ok := s.applied[op.ID]; ok {
prev.Applied = false // 标记「本次未生效」,调用方据此决定要不要回包
return prev, nil
}
// ……执行具体变更……
res := FriendResult{Applied: true, State: s.get(op.From, op.To)}
s.applied[op.ID] = res // 首次结果落进窗口,后续重发命中它
return res, nil
}
窗口本身也要有上界。客户端 opID 单调递增,所以可以按水位线淘汰:记住「见过的最大 opID」,把明显低于它的旧条目清掉。这和卷一 11.1 用时间桶给出内存上界是同一个思路——没有上界的缓存迟早会变成事故。
一致性:半边检测与补偿
即使写操作都成对,系统仍可能因为进程在两次写之间崩溃、或持久化只落了一半而留下半边。检测的办法就是拿不变量当断言:遍历所有边,任何一条 RelFriend 找不到反向的 RelFriend,就是一个待修复的半边。补偿策略取「以存在的那一边为准」补齐反向边——这偏向于「保留关系」,因为误删好友比误加好友更难被玩家发现和纠正。下面这段是 L1:
// file: internal/chat/friend_consistency.go
// tier: L1
// verify: go test ./internal/chat/
package chat
import "sort"
// Check 校验「双向一致」不变量:任何一条 RelFriend 边都必须在两个方向同时存在。
// 返回所有只剩半边的 (from, to) 对,供补偿任务修复。
func (s *FriendService) Check() [][2]PlayerID {
s.mu.Lock()
defer s.mu.Unlock()
var broken [][2]PlayerID
for a, m := range s.edges {
for b, r := range m {
if r == RelFriend && s.get(b, a) != RelFriend {
broken = append(broken, [2]PlayerID{a, b})
}
}
}
sort.Slice(broken, func(i, j int) bool {
if broken[i][0] != broken[j][0] {
return broken[i][0] < broken[j][0]
}
return broken[i][1] < broken[j][1]
})
return broken
}
// Repair 把只剩半边的 RelFriend 补齐成双向(以存在的那一边为准),
// 返回修复的边数。离线补偿任务调用它,实现最终一致。
func (s *FriendService) Repair() int {
s.mu.Lock()
defer s.mu.Unlock()
type pair struct{ a, b PlayerID }
var broken []pair
for a, m := range s.edges {
for b, r := range m {
if r == RelFriend && s.get(b, a) != RelFriend {
broken = append(broken, pair{a, b})
}
}
}
for _, p := range broken {
s.out(p.b)[p.a] = RelFriend
}
return len(broken)
}
Check 在测试里被当成断言用:任何一次合法操作之后,Check() 都必须返回空。它不是给线上常跑的(那是 O(E) 的全图扫描),而是给单元测试和离线巡检用的——把不变量写成能跑的断言,是让「关系一致性」从口头约定变成机器可验证的关键一步。
本节小结
好友关系的三件事:用有向边建模,让「申请/拉黑」的不对称和「好友」的对称各归其位;用全局唯一的 opID 幂等窗口挡住重发,复用卷一 11.1 的思路但把作用域从单连接扩到全服;用「双向一致不变量」把正确性变成一条随时可断言的检查,再用补偿任务兜住崩溃留下的半边。下一节回到组队,看一组更强的约束:同一时刻一支队伍只能有一个队长、只能待在一个副本里。
阅读导航:上一节:11.2 敏感词与限速治理 · 下一节:12.1 队伍状态与入口控制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。