本专栏的项目代码在Github上,如果感兴趣的话,麻烦点点Star,谢谢啦。
在游戏业务中,数据的变动非常的频繁,并且要求延迟低(像 MOBA 这种,延迟10 ms都能有感觉),所以采用一般的数据缓存方案无法满足要求,因此大部分数据都需要加载到内存中,然后查询和修改都是基于内存操作的,然后再异步将脏数据写入数据库。
接口定义
首先定义一个 Entity
接口,用来表示数据库中的对象,没有方法,仅仅作为一个标记作用,方便后面的一些逻辑识别。
interface Entity
然后定义 MemData
,表示数据库中的数据在内存中的管理类,包含一个 init
方法,用于初始化内存数据。
interface MemData<E> where E : Entity {
fun init()
}
对于加载到内存后不需要变动的数据,到这一步,其实就已经完成了。真正复杂的部分在于那些加载到内存中的变动数据,如何进行存库。
对于不变的数据,假设我们有一个 WorkerId
的 Entity
,那么 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()
结尾
以上,我们完成了这个框架中比较麻烦的部分,限于篇幅的原因,很多细节没有在文章中展示,感兴趣的话可以自行阅读源码。