Akka分布式游戏后端开发8 游戏数据加载与存库

98 阅读5分钟

本专栏的项目代码在Github上,如果感兴趣的话,麻烦点点Star,谢谢啦。

在游戏业务中,数据的变动非常的频繁,并且要求延迟低(像 MOBA 这种,延迟10 ms都能有感觉),所以采用一般的数据缓存方案无法满足要求,因此大部分数据都需要加载到内存中,然后查询和修改都是基于内存操作的,然后再异步将脏数据写入数据库。

接口定义

首先定义一个 Entity 接口,用来表示数据库中的对象,没有方法,仅仅作为一个标记作用,方便后面的一些逻辑识别。

interface Entity

然后定义 MemData,表示数据库中的数据在内存中的管理类,包含一个 init 方法,用于初始化内存数据。

interface MemData<E> where E : Entity {
    fun init()
}

对于加载到内存后不需要变动的数据,到这一步,其实就已经完成了。真正复杂的部分在于那些加载到内存中的变动数据,如何进行存库。

对于不变的数据,假设我们有一个 WorkerIdEntity,那么 MemData 的实现可能如下:

class WorkerIdMem(private val mongoTemplate: () -> MongoTemplate) : MemData<WorkerId> {
    private val workerIdByAddr: MutableMap<String, WorkerId> = mutableMapOf()

    override fun init() {
        val template = mongoTemplate()
        val workerIdList = template.findAll(WorkerId::class.java)
        workerIdList.forEach {
            workerIdByAddr[it.addr] = it
        }
    }
}

只需要查库,然后塞数据,另外可能再做一下增删改查的方法就好了。对于变动的内存数据,我们做不到数据每变动一次,就执行一次写数据库操作,那样会带来非常高的数据库 IO,通常会是定时刷一次,或者用算法去标记某些脏数据,只把脏数据写入数据库。这里我们使用第二种,通过标记脏数据的方式。为此,我们再定义一种 MemData,叫 TraceableMemData,提供脏数据追踪功能:

abstract class TraceableMemData<K, E>(
    entityClass: KClass<E>,
    kryoPool: KryoPool,
    coroutine: TrackingCoroutineScope,
    mongoTemplate: () -> MongoTemplate
) : MemData<E> where K : Any, E : Entity {
    private val tracer = Tracer<K, E>(entityClass, kryoPool, coroutine, 2.minutes, 1.seconds, mongoTemplate)

    abstract fun entities(): Map<K, E>

    /**
     * Trace all entities
     */
    fun traceEntities() {
        tracer.trace(entities())
    }

    /**
     * Mark all entities as cleanup
     */
    fun markCleanup() {
        tracer.cleanupAll(entities())
    }

    /**
     * Flush all entities
     * @return true if all entities are flushed
     */
    fun flush(): Boolean {
        return tracer.flush(entities())
    }
}
  • entities 返回此 MemData 中现存的所有数据
  • traceEntities 对现存的 entities 做一次标脏操作
  • markCleanup 将现存的 entities 标记为干净的,一般在加载完数据之后做一次
  • flush 强制对所有数据进行检测,将脏数据写库,一般在玩家下线或者停服的时候操作

Tracer

由于这部分的代码比较多,因此只能说一个思路以及展示部分代码

其实标脏的思路也很简单,就是对 Entity 中的字段做哈希,当然,为了兼顾性能与内存,不可能做到对每个字段都做标脏,只做了 Entity 中的直接字段的标脏,不再对字段的字段单独做哈希。为此,我们就可以定义一个数据结构,来记录上一次的哈希值,用于和下一次哈希做比较:

/**
 * @param hashSameCount: the count of the same hash code
 * @param hashCode: the hash code of the object
 * @param fullHashCode: the full hash code of the object
 * @param status: mark the object is dirty or not
 * @param value: if dirty, save the object
 */
data class Record(
    var hashSameCount: Int,
    var hashCode: Int,
    var fullHashCode: HashCode,
    var status: Status,
    var value: Any?,
) {
    companion object {
        fun default(): Record {
            return Record(0, 0, HashCode.fromInt(0), Status.Clean, null)
        }
    }
}

这里做下说明,由于只比较哈希可能会出现哈希冲突的情况(数据变了但是哈希没变),这个时候就需要做一次二进制的哈希,将这个字段序列化成二进制再比较一次哈希。当然这个操作是在多次哈希都相同的情况才做,不然每次都做成本太高了。

通过反射获取 Entity 中的字段

既然要对字段进行标脏,那自然需要迭代 Entity 中的字段,这里我们需要预先通过反射获取到 Entity 中的字段,后续直接迭代这些字段执行反射调用,就可以获取字段的值了。

在存储字段的时候,我们会对普通的字段和 Map 类型的字段分开存储,它们的处理逻辑不同,对于 Map 类型的字段,我们不直接对整个 Map 做哈希,而是对这个 Map 进行迭代,分别对每个元素做哈希。

private val normalFields: List<KProperty1<E, *>> = normalFields()

//<id, <field, value> >
private val normalValues: MutableMap<K, MutableMap<String, Record>> = mutableMapOf()

private val mapFields: List<KProperty1<E, Map<*, *>>> = mapFields()

//<id, <field, <key, value> > >
private val mapValues: MutableMap<K, MutableMap<String, MutableMap<Any?, Record>>> = mutableMapOf()
  • normalValues 存储普通字段的哈希记录
  • mapValues 存储 Map 字段的哈希记录
private fun normalFields(): List<KProperty1<E, *>> {
    return entityClass.declaredMemberProperties.filter {
        !it.returnType.jvmErasure.isSubclassOf(Map::class) && it is KMutableProperty1<E, *>
    }
}

这里有个优化项,我们只需要取可变类型的字段,不可变的类型就不取了,节省一点资源。

private fun mapFields(): List<KProperty1<E, Map<*, *>>> {
    @Suppress("UNCHECKED_CAST")
    return entityClass.declaredMemberProperties.filter {
        it.returnType.jvmErasure.isSubclassOf(Map::class)
    } as List<KProperty1<E, Map<*, *>>>
}

计算哈希

/**
 * 计算哈希值,如果哈希值发生变化,将计算哈希的对象存入[Record.value]中,用于写库,并标记为[Status.Set]
 * 计算哈希时,首先会计算普通哈希值,如果这个对象连续多次计算普通哈希都相同,那么会计算一次复杂哈希
 * 复杂哈希是通过将这个对象进行序列化之后计算的
 */
private fun hash(name: String, obj: Any?, record: Record) {
    if (record.status.isDirty()) {
        //虽然这个字段已经被标记为脏,但是后续也可能会继续改动此值,需要保持脏数据为最新值
        record.value = obj
        logger.trace("field:{} is dirty, skip", name)
        return
    }
    val preHashCode = record.hashCode
    record.hashCode = obj.hashCode()
    if (preHashCode != record.hashCode) {
        record.hashSameCount = 0
        record.status = Status.Set
        record.value = obj
    } else {
        record.hashSameCount++
    }
    if (record.status.isDirty()) {
        logger.trace("field:{} is dirty", name)
        return
    }
    if (record.hashSameCount >= FULL_HASH_THRESHOLD || flushing) {
        val preFullHashCode = record.fullHashCode
        record.fullHashCode = fullHashCode(obj)
        if (preFullHashCode != record.fullHashCode) {
            record.status = Status.Set
            record.value = obj
            logger.trace("field:{} is dirty", name)
        }
        record.hashSameCount = 0
    }
}

private fun fullHashCode(obj: Any?): HashCode {
    return if (obj == null) {
        HashCode.fromInt(0)
    } else {
        kryoPool.use {
            Output(ByteArrayOutputStream()).use {
                writeObject(it, obj)
                hashFunction.hashBytes(it.toBytes())
            }
        }
    }
}

生成数据库更新操作

由于我们使用的是 MongoDB 数据库,所以只需要生成 $set$unset 指令就行了,从上面计算哈希的代码可以看出,当我们计算出哈希值发生变化的时候,会将 record 中的 status 更改为 Status.Set

enum class Status {
    Clean,
    Set,
    Unset,
    ;

    fun isDirty(): Boolean {
        return this != Clean
    }
}

对于 Map 类型的字段,有三种情况

  • 新增数据,需要生成 $set 操作,并将新的数据记录到 mapValues
  • 删除数据,需要生成 $unset 操作,并删除 mapValues 中的记录
  • 修改数据,需要生成 $set 操作,并更新 mapValues 中的记录
private fun traceMapField(
    property: KProperty1<E, Map<*, *>>,
    entity: E,
    valueByFieldName: MutableMap<String, MutableMap<Any?, Record>>
) {
    val name = property.name
    val currentMap = property.get(entity)
    logger.trace("trace map field:{}, value:{}", name, currentMap)
    val valueByMapKey = valueByFieldName.computeIfAbsent(name) { mutableMapOf() }
    valueByMapKey.forEach { (k, v) ->
        if (k !in currentMap) {
            v.status = Status.Unset
        }
    }
    currentMap.forEach { (k, v) ->
        val record = valueByMapKey.computeIfAbsent(k) { Record.default() }
        hash("$name.$k", v, record)
    }
}

在这里我们只是简单的计算了一下哈希,并修改了 status,其它修改会在真正生成数据库操作之后才进行,并且需要考虑数据库操作失败后的重试问题。

更新数据

val value = record.value
val criteria = Criteria.where(idField.name).`is`(id)
val update = Update.update("$fieldName.$k", deepCopy(value))
updateOpList.add(UpdateOp(Query.query(criteria), update, record))
cleanup("$fieldName.$k", value, record)

我们将数据库操作准备好,然后清除这个字段的脏状态,后面这些操作会在 IO 线程中执行,对于 $set 操作,我们需要使用 upsert,覆盖插入数据和更新数据两种情况。

删除数据

val criteria = Criteria.where(idField.name).`is`(id)
val update = Update().unset("$fieldName.$k")
updateOpList.add(UpdateOp(Query.query(criteria), update, record))

执行

val updateResults = updateOpList.map { updateOp ->
    async(Dispatchers.IO) {
        runCatching {
            if (updateOp.record.status == Status.Unset) {
                template.updateFirst(updateOp.query, updateOp.update, entityClass.java)
            } else {
                template.upsert(updateOp.query, updateOp.update, entityClass.java)
            }
        }
    }
}.awaitAll()

结尾

以上,我们完成了这个框架中比较麻烦的部分,限于篇幅的原因,很多细节没有在文章中展示,感兴趣的话可以自行阅读源码。