一种增量式的状态同步方案

前言

在游戏开发中,前后端的数据同步往往有两种方案:

对于实时性要求较高、用户交互较为频繁 的游戏,例如实时策略游戏(RTS)、格斗游戏、MOBA类游戏,一般采用帧同步 方案; 对于实时性要求不高 的游戏,如卡牌等,一般采用状态同步方案。

如何高效地进行数据同步成为了值得研究的问题,以下是一种增量式的状态同步方案。

如何同步?

游戏前后端的交互都是通过自定义协议进行的,由于状态同步对实时性要求不高,因此我们可以在每条协议上加一个字段。

当每次进行协议交互时,后端把当前需要同步的数据附加在额外字段上,让附加数据"搭便车",从而实现数据状态同步。

如下:在S2C协议中加上remote字段,表示从远程同步的数据。

proto3 复制代码
message C2SRecruit {
  int32 no = 1;
  string userId = 2;
  string recruitType = 3;
  int32 count = 4;
}

message S2CRecruit {
  int32 no = 1;
  int32 code = 2;
  string data = 3;
  string remote = 4;
}

后端如何附加数据?

游戏服维护一个数据同步器组件,该组件统一维护当前游戏服所有玩家需要同步的数据。

当玩家数据变更时把数据上传到同步器中,同步器按玩家id收集需要同步的数据。

go 复制代码
/**
 * 数据同步器
 */
type CacheData map[string]interface{}

type SyncHronizer struct {
    SyncCache map[string]CacheData
}

func NewSyncHronizer() *SyncHronizer {
    synchronizer := &SyncHronizer{}
    synchronizer.OnInit()
    return synchronizer
}

func (slf *SyncHronizer) OnInit() {
    slf.SyncCache = make(map[string]CacheData)
}

func (slf *SyncHronizer) BuildPlayerCache(playerId string, key string, value interface{}) {
    cache := slf.GetPlayerCache(playerId)
    cache[key] = value
    slf.SyncCache[playerId] = cache
}

func (slf *SyncHronizer) GetPlayerCache(playerId string) CacheData {
    playerCache, exists := slf.SyncCache[playerId]
    if !exists {
        playerCache = make(CacheData)
    }
    return playerCache
}

func (slf *SyncHronizer) DelPlayerCache(playerId string) {
    cache := slf.SyncCache[playerId]
    if cache == nil {
        return
    }
    delete(slf.SyncCache, playerId)
}

当接收到前端协议时,先执行对应的游戏操作,最后在返回协议前向同步器查询当前玩家是否有带同步数据,若有则附加到remote字段上,并清空同步器中对应玩家的待同步数据。

go 复制代码
func (slf *Game) SyncPlayerData(playerId string, res *app.Gate2GameRes) *app.Gate2GameRes {
    cache := slf.SyncHronizer.GetPlayerCache(playerId)      // 获取玩家待同步数据
    if len(cache) == 0 {
        return res
    }
    remote, err := json.Marshal(cache)                      // 打包待同步数据
    if err != nil {
        log.SError("marshal remote error: ", err)
        return res
    }
    res.Remote = string(remote)                             // 将数据附加到remote字段
    slf.SyncHronizer.DelPlayerCache(playerId)               // 清空玩家待同步数据
    player, exist := slf.players[playerId]
    if !exist {
        return res
    }
    player.ClearDirty()
    return res
}

如何增量更新

玩家的数据可能是复杂的、多层嵌套的,当一个庞大的map只有一个键值对被修改时,如果将整个map同步到前端是不划算的。

因此我们可以设计一个数据同步组件,将该组件附加到玩家对象上。 当玩家登录初始化时,将玩家数据完整复制一份保存在数据同步组件中作为上次数据更新的副本。

当玩家数据更新时,对该字段手动执行数据更新,数据同步组件会将该字段值与上次更新数据进行对比。

如果存在差异则将差异的路径转换成点语法形式,并以map形式上传到游戏服的数据同步器中。

这样,当一个多层嵌套的map中一个键值对被修改时,后端只会同步被修改的键值对的路径,而不会全量同步整个map。

go 复制代码
func (slf *SyncComponent) UpdateCache(player *Player, key string) map[string]interface{} {
    syncKeys := slf.GetSyncKeys()
    ingoreKeys := GetIngoreKeys()
    if !arrutil.Contains(syncKeys, key) || arrutil.Contains(ingoreKeys, key) {      // 忽略不需要同步的字段
        return nil
    }
    data := player.GetPlayerData().(PlayerData)
    val := reflect.ValueOf(data).FieldByName(key)
    if !val.IsValid() {
        return nil
    }
    value := val.Interface()
    cache := slf.cache[key]
    if common.Equal(value, cache) {                 // 数据与上次同步数据相同,则无需同步
        return nil
    }
    playerId := player.GetPlayerId()
    gameServer := node.GetService("Game").(defines.IGameServer)
    diff := common.Compare(cache, value)
    if cache == nil || diff == nil {
        gameServer.UploadPlayerData(playerId, key, value)               // 首次同步或数据完全不同,直接上传
        slf.cache[key] = deepcopy.DeepClone(value)
        return map[string]interface{}{
            key: value,
        }
    }

    for k, v := range diff {                            // 存在差异,则转换成点语法后上传差异
        k = key + "." + k
        gameServer.UploadPlayerData(playerId, k, v)
    }
    slf.cache[key] = deepcopy.DeepClone(value)
    return diff
}

同时,可以提供一个特殊的同步接口,可以自行选择同步的颗粒度或是自定义同步数据格式。

比如背包的数据可以以背包格子为单位进行更新,或自定义特殊的奖励返回字段。

go 复制代码
func (slf *SyncComponent) SyncExtra(player *Player, path string, value interface{}) {
    dirty := slf.dirty[path]
    switch val := value.(type) {
        case map[string]interface{}:
            if common.Equal(dirty, value) {             // 数据与上次同步数据相同,则无需同步
                break
            }
            for k, v := range val {
                if dirty == nil {
                    dirty = make(map[string]interface{})
                }
                dirty.(map[string]interface{})[k] = v           // 直接上传特殊同步数据
            }
        default:
            if dirty == value {
                break
            }
            dirty = value
    }
    slf.dirty[path] = dirty
    playerId := player.GetPlayerId()
    gameServer := node.GetService("Game").(defines.IGameServer)
    gameServer.UploadPlayerData(playerId, path, dirty)
}

实际效果

当前游戏有一个角色招募系统,当玩家进行招募时需要记录玩家招募的总次数、消耗招募道具后的道具剩余数量等。

相关推荐
muxue17819 分钟前
go:运行第一个go语言程序
开发语言·后端·golang
米饭好好吃.20 分钟前
【Go】Go wire 依赖注入
开发语言·后端·golang
闲猫20 分钟前
go 接口interface func (m Market) getName() string {
开发语言·后端·golang
Good Note21 分钟前
Golang的静态强类型、编译型、并发型
java·数据库·redis·后端·mysql·面试·golang
可爱de艺艺21 分钟前
Go入门之struct
开发语言·后端·golang
信徒_24 分钟前
Go 语言中的协程
开发语言·后端·golang
m0_7482365837 分钟前
跟据spring boot版本,查看对应的tomcat,并查看可支持的tomcat的版本范围
spring boot·后端·tomcat
web1511736022343 分钟前
Spring Boot项目中解决跨域问题(四种方式)
spring boot·后端·dubbo
我就是我3521 小时前
记录一次SpringMVC的406错误
java·后端·springmvc
过客猫20223 小时前
使用 deepseek实现 go语言,读取文本文件的功能,要求支持 ascii,utf-8 等多种格式自适应
开发语言·后端·golang