一、先定关系方向,再谈功能
好友系统最容易犯的错,是把“我加了你”和“我们是好友”混成一条记录。前者是单向申请,后者是双向关系;前者可以重复、可以过期,后者必须成对存在。数据模型没分开,后面每个功能都要打补丁。
一条关系至少要有拥有者、目标玩家、关系类型、状态和更新时间。双向好友建议再存一个归一化的 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 保证成对写入;删除时同时清理关系和残留申请;黑名单是最高优先级的关系否决位,必须由服务端统一拦截。跨服场景把持久关系和短生命周期在线状态拆开,查询时再组合。
常见错误与边界
- 好友关系只写一边,读取时依赖某一侧的缓存,跨服后直接不一致。
- 删除好友不删申请,重新添加被误判为重复申请,或者绕过确认恢复关系。
- 黑名单只过滤列表,私聊和组队接口仍按好友关系放行。
- 申请时不校验数量上限,待处理列表被脚本刷满。
- 同意时不在事务内完成,进程崩溃后留下半条关系。
- 把在线状态写进好友表,重启或分区后长期显示错误。
- 同一对玩家没有唯一键,双向写入产生两条重复关系。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。