中级游戏后端的逆袭之路——常规游戏功能设计(三、好友系统)

中级游戏后端的逆袭之路——常规游戏功能设计(三、好友系统) 功能添加、删除好友推荐好友屏蔽好友关键挑战高并发读写热门玩家可能有数千好友。数据一致性分布式环境下避免重复添加、丢失操作。存储成本全量关系表膨胀快需合理分片。实时性推荐结果需快速返回。使用eventlog机制保证申请成功使用该方案的原因好友申请即使丢弃了对于玩家来说影响相对较小重新发起即可实现简单eventlog 只需要一张 MySQL 表和一个后台消费进程无需引入 WAL 文件、消息队列等高成本组件开发和运维成本大幅降低。性能足够eventlog 表使用自增 ID 或时间戳作为主键写入是顺序 IO单表轻松支撑每秒几千次写入。配合定时批量合并完全可以满足大多数游戏的需求。缓存设计本地缓存L1进程内LRU缓存存放最常访问的好友列表如最近互动的50人。读写速度纳秒级彻底消除网络开销。Redis缓存L2全局共享缓存存放完整的好友关系如所有好友ID列表、黑名单。读写微秒级支撑跨进程共享。MySQLL3持久化存储最终数据底座。为什么需要三级本地缓存应对极高QPS的热点用户如明星玩家被大量查询避免Redis被打爆。Redis跨节点共享数据保证同一用户在不同服务器看到的数据基本一致。MySQL保证数据不丢提供历史回溯和复杂查询。一致性最终一致性好友请求可以接受短暂延迟使用消息队列Kafka/RocketMQ异步落库。冲突处理同时添加好友可能产生重复记录利用唯一索引捕获异常后重试或幂等。缓存实现过程中可能遇到的挑战典型场景用户A添加好友B操作顺序如下写入本地缓存A的好友列表增加B。写入RedisA的zset增加B。发送消息到队列异步写入MySQL。问题步骤3可能失败MySQL宕机、队列积压后丢弃导致Redis中有B而MySQL中没有。后续重启服务或缓存失效时数据永久丢失。核心矛盾性能要求低延迟vs 可靠性要求不丢数据。方案对比本方案选择Write Behind WALWrite-Ahead Log。理由好友系统的写操作添加/删除远少于读操作但对延迟敏感用户点击后希望立即反馈。先写缓存可让用户立刻感知操作成功异步写DB即使慢几秒也无妨。WAL机制保证即使服务崩溃未落盘的操作也能从日志恢复。缓存与DB最终一致性由于写操作总是先更新缓存后写DB可能存在短暂的不一致DB落后于缓存。但这是可接受的因为用户操作后立刻看到效果缓存已更新。其他用户读取时可能看到旧数据从DB读取但概率极低大多数读走缓存。通过定期巡检任务如每小时扫描Redis中的好友列表与DB比对修复保证长期一致。详细实现本地缓存fromcachetoolsimportTTLCacheclassLocalFriendCache:def__init__(self,maxsize1000,ttl60):self.cacheTTLCache(maxsizemaxsize,ttlttl)defget(self,user_id):returnself.cache.get(user_id)defset(self,user_id,friend_list):self.cache[user_id]friend_listdefinvalidate(self,user_id):self.cache.pop(user_id,None)redis层classRedisFriendCache:def__init__(self,redis_client):self.redisredis_clientasyncdefget_friends(self,user_id):returnawaitself.redis.zrevrange(ffriend_set:{user_id},0,-1)asyncdefadd_friend(self,user_id,friend_id):pipeself.redis.pipeline()pipe.zadd(ffriend_set:{user_id},{friend_id:time.time()})pipe.zadd(ffriend_set:{friend_id},{user_id:time.time()})awaitpipe.execute()异步持久化WAL 队列核心思路每次写操作先写入本地WAL日志文件append-only再更新缓存最后异步发送到消息队列由消费者写入MySQL。服务重启时扫描WAL恢复未完成的操作。WAL日志格式JSON Lines{op:add,user_id:1,friend_id:2,timestamp:1234567890,uuid:xxx}{op:remove,user_id:1,friend_id:3,timestamp:1234567891,uuid:yyy}importaiofilesimportjsonimportuuidfromdatetimeimportdatetimeclassWriteAheadLog:def__init__(self,log_path/var/log/friend_wal.log):self.log_pathlog_path self.buffer[]# 内存缓冲区批量刷盘asyncdefappend(self,operation:dict):追加一条WAL记录同步写磁盘保证不丢record{**operation,uuid:str(uuid.uuid4()),created_at:datetime.utcnow().isoformat()}linejson.dumps(record)\nasyncwithaiofiles.open(self.log_path,modea)asf:awaitf.write(line)awaitf.flush()# 强制刷盘returnrecordasyncdefreplay(self,callback):启动时回放所有未完成的WAL记录try:asyncwithaiofiles.open(self.log_path,moder)asf:asyncforlineinf:recordjson.loads(line.strip())awaitcallback(record)exceptFileNotFoundError:passasyncdeftruncate(self,upto_uuid):截断已处理的记录生产环境可用logrotate# 简化重写文件去掉已处理的记录pass操作处理器classFriendOperationHandler:def__init__(self,wal:WriteAheadLog,redis_cache:RedisFriendCache,local_cache:LocalFriendCache,queue):self.walwal self.redisredis_cache self.locallocal_cache self.queuequeue# 消息队列生产者asyncdefadd_friend(self,user_id,friend_id):# 1. 写入WAL保证不丢recordawaitself.wal.append({op:add,user_id:user_id,friend_id:friend_id})# 2. 更新本地缓存立即生效self._update_local_cache(user_id,friend_id,actionadd)self._update_local_cache(friend_id,user_id,actionadd)# 3. 更新Redisawaitself.redis.add_friend(user_id,friend_id)# 4. 发送异步消息到队列最终写入MySQLawaitself.queue.send(friend_write,record)return{success:True}def_update_local_cache(self,uid,fid,action):cachedself.local.get(uid)ifcachedisnotNone:ifactionadd:iffidnotincached:cached.append(fid)elifactionremove:iffidincached:cached.remove(fid)self.local.set(uid,cached)MySQL消费者classFriendDBWriter:def__init__(self,db_pool,wal:WriteAheadLog):self.dbdb_pool self.walwalasyncdefprocess_message(self,record):消费队列消息写入MySQLoprecord[op]user_idrecord[user_id]friend_idrecord[friend_id]uuidrecord[uuid]try:asyncwithself.db.acquire()asconn:ifopadd:# 插入两条记录正向反向awaitconn.execute(INSERT IGNORE INTO friend_relation (user_id, friend_id, status) VALUES (:u1, :f1, 1), (:u2, :f2, 1),{u1:user_id,f1:friend_id,u2:friend_id,f2:user_id})elifopremove:awaitconn.execute(DELETE FROM friend_relation WHERE (user_id:u1 AND friend_id:f1) OR (user_id:u2 AND friend_id:f2),{u1:user_id,f1:friend_id,u2:friend_id,f2:user_id})# 标记WAL记录已处理可另建一张processed_log表awaitself._mark_processed(uuid)exceptExceptionase:# 失败则重试消息队列自带重试机制raisee崩溃恢复服务启动时执行asyncdefstartup_recovery(wal:WriteAheadLog,db_writer:FriendDBWriter,redis_cache:RedisFriendCache):回放WAL重建缓存和DB的一致性processed_uuidsawaitget_processed_uuids_from_db()# 从MySQL获取已处理的UUID集合asyncdefreplay_callback(record):ifrecord[uuid]notinprocessed_uuids:# 该操作未被MySQL处理重新执行awaitdb_writer.process_message(record)# 确保Redis中存在幂等awaitredis_cache.add_friend(record[user_id],record[friend_id])awaitwal.replay(replay_callback)分布式设计数据分片按用户ID哈希分片将好友关系表水平拆分到多个MySQL实例如256个分片。查询某个用户的好友时只需路由到对应分片。Redis集群使用Redis Clusterkey中包含user_id即可自动散列。跨服务调用推荐好友可能需要用户基本信息昵称、头像应通过RPC或gRPC调用用户服务并设置超时熔断。好友变更事件如新增好友可发布到消息总线通知聊天、排行榜等服务。容灾与限流每个API接口做限流令牌桶例如每秒每个用户最多10次好友操作。写操作先写入本地队列后台线程批量刷入DB防止突发流量打垮数据库。