《MMO 服务器开发》11.3 好友关系与关系一致性

好友关系是 MMO 里最容易不一致的共享状态:它是双向的,一次只写一半就会留下「半边关系」。本节把关系图建成有向边、把双向一致写死成不变量、用全局唯一的 opID 幂等窗口挡住重发,并给出半边检测与补偿的实现,呼应卷一的幂等窗口与顺序保证,让关系一致从口头约定变成机器可验证的断言。

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 队伍状态与入口控制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标