前言
在游戏开发中,前后端的数据同步往往有两种方案:
对于实时性要求较高、用户交互较为频繁 的游戏,例如实时策略游戏(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)
}
实际效果
当前游戏有一个角色招募系统,当玩家进行招募时需要记录玩家招募的总次数、消耗招募道具后的道具剩余数量等。