数据库代理服务(概述)

前言


在网络游戏后端开发中,如何高效服务海量玩家的数据存储和查询需求是一项颇具挑战的任务。 与传统的Web应用不同,玩家存档数据变更和存储更加的频繁,在整个游戏过程中随着游戏的进度的推进会不断的产生查询和变更,这就对数据库提出了很高的要求:高并发,低延迟。 为了满足高并发,低延迟的要求,引入缓存是实现此目标的常见优化方式,比如引入开源组件:Redis,Memcached,对于有更高要求的常见下甚至会使用进程内存来缓存各种数据。
通常实现该功能的模块,一般叫这种模块为:“数据库代理服务/模块”。 为此我们将设计一套通用的数据库代理方案,能够高效的可靠的处理数据存储与查询需求。

该方案仅适合用于游戏场景下的需求,非通用解决方案

目标


首先我们将列出一些目标,作为我们接下来设计的要点存在。

与存储解耦

即业务方使用我们提供的SDK接口进行CRUD操作,而非数据库提供的操作语句,比如SQL,业务不需要再去学习复杂繁琐的类SQL语句即可进行数据的操作。

支持多语言

游戏服务器通常有各种语言实现去编写比如Java/C#/C++/Go,需要提供本地化的开发体验,并且在需要的情况下可以扩展更多语言的支持。

支持增量同步

数据的变更应该只需要同步差异的那部分数据,而非全量数据,同样的如果后端存储支持增量更新则一样需要予以支持,不支持的情况下可以退化为全量存档。

支持批量操作

需要支持批量查询/更新/删除等操作。

支持多种存储后端

首先考虑支持Mongodb,后续可扩展Mysql等数据库,注意扩展数据库不应该破坏现有数据接口。

支持数据恢复

使用binlog机制实现数据恢复,即使进程宕机,服务器宕机的情况下也可以恢复绝大部分数据(部分未同步数据在游戏服务器侧内存中尚未同步成功)。

支持淘汰机制

支持本地淘汰和远程淘汰,在不需要数据时可主动或者被动淘汰,主动腾出内存空间。

支持特殊需求

因为业务和数据库已经被数据库代理隔离,因此某些业务需求无法简单的实现,数据库代理也需要提供接口支持。

  1. 按类型生成自增序号
  2. 按类型分配/释放唯一字符串

支持多种传输方式

默认基于gRPC实现,允许扩展HTTP/TCP支持。

支持集群模式

初步版本支持静态集群(客户端hash),后续版本需要支持不停服动态扩容服务节点。

设计


设计分为两大块,一块是客户端SDK设计,另一块是后端数据库代理设计,在此之前我们需要约定好SDK与数据库代理的调用数据传序列化格式和传输约定。

序列化方式

  1. Protobuf (默认)
  2. Json (配合HTTP传输协议使用)

传输协议

  1. gRPC (默认实现,支持多语言)
  2. HTTP (备选方案,用于支持没有gRPC支持的语言下使用)
  3. TCP (备选方案,暂不实现)

模型描述

使用Protobuf进行表格模型描述,扩展注解语法,实现额外的元数据描述。 基于上述模型描述生成各种代码,业务只需要按照规则编写模型配合代码生成器就可以方便的使用。

传输层定义

使用Proto文件描述与数据库代理交互的接口,此接口将作为传输层核心接口,必须保证数据兼容性。

syntax = "proto2";

package protocol;

service TableService {

  // CheckTable
  // 检查表结构一致性
  rpc CheckTable(CheckTableArgs) returns(CheckTableReply);

  // HandShake
  // 请求握手
  rpc Handshake(HandshakeArgs) returns(HandshakeReply);

  // GetTable
  // 提供一个主键并获取改主键对应的表数据
  rpc GetTable(GetTableArgs) returns(GetTableReply);

  // GetManyTable
  // 提供一系列主键列表,返回成功加载到的表数据列表
  rpc GetManyTable(GetManyTableArgs) returns(GetManyTableReply);

  // SelectTable
  // 提供一个弱键,返回该弱键下关联的全部数据列表
  rpc SelectTable(SelectTableArgs) returns(SelectTableReply);
  
  // PatchMultiTable
  // 跨表脏数据合并,支持多张表
  rpc PatchMultiTable(PatchMultiTableArgs) returns(PatchMultiTableReply);

  // EvictTable
  // 主动淘汰内存数据
  rpc EvictTable(EvictTableArgs) returns(EvictTableReply);

  // Acquire
  // 占用唯一键值 (用于实现例如:玩家名字唯一性,工会名唯一性)需要确保唯一性值的场景
  rpc AcquireValue(AcquireValueArgs) returns(AcquireValueReply);

  // Release
  // 释放唯一键值
  rpc ReleaseValue(ReleaseValueArgs) returns(ReleaseValueReply);

  // IncrBy
  // 数值自增
  rpc IncrBy(IncrByArgs) returns(IncrByReply);
}

模型注解定义

在定义表格模型时我们需要一些额外的元数据帮助代码生成器了解数据模型定义,比如表格模型对应的表名,索引配置,最大长度等。 在这里我们过特殊的注释语法进行标注,注意这里没有采用protobuf推荐的方式customoptions去扩展语法,而是采取类Java注解的方式,简化注解的使用。

@Table

数据表模型主解@Table,标注在数据表的message定义上,用于指示这是一张映射数据库的表描述定义。

  • @Table(name = “dh_player”)
    • name 用于指定数据库表名
//@Table(name = "dh_player")
message Player {
  // ...
}

@Index

数据表索引主解@Index,标注在数据表的message定义上,用于指定数据库表的索引定义(目前只支持主键索引定义),索引的修改会自动同步到数据库,但是不包含删除已有索引。

  • @Index(keys = [“key”, “slot”], primary = true)
    • keys 代表索引字段列表,只允许使用数字或者字符串的字段做索引(不推荐使用字符串做键)
    • primary 代表这个索引是主键索引,主键索引有且必须有一个
//@Table(name = "dh_backpack")
//@Index(keys = ["key", "slot"], primary = true)
message Backpack {
  optional int64 key = 1;
  optional int32 slot = 2;
  // ...
}

@Length

字段长度主解@Length,必须标注在任意变长数据类型的字段上。

为了确保数据安全,变长长度二次修改原则上必须比原来的长度要大

  • @Length(len = 64)
    • 指示数据(bytes/string)的长度或者容器类型(list/map)的容量
  • @Length(len = 128, val = 32)
    • 指示容器类型(list/map)容量,和容器val类型(string/bytes)的长度
  • @Length(len = 64, key = 20, val = 60)
    • 指示map类型的容量,key类型(string)的长度和val类型(string/bytes)的长度

增量同步

维基百科解释增量计算:

增量计算是一种软件功能 。当一部分的数据产生了变化,就仅对该产生变化的部分进行计算和更新,以节省计算时间。相比于简单地重复计算完整的输出内容,增量计算能够显著地节省计算时间。 比如,电子表格会在实现重计算功能时使用增量计算,只重新计算并更新那些含有公式且被直接或间接地改变了的单元格。

增量同步和增量计算的差异就在⌈同步⌋上,增量计算是本地行为而增量同步是本地和远程协作行为,本地通过增量计算获取变更部分的差异数据,并通过网络传输到远程计算上,并通过合适的数据合并算法,最终本地变更后的数将和远程数据合并后数据保持一致,即保持了⌈同步⌋。

记录数据的⌈变化⌋采用Bitmask的方式进行标脏记录,当对应的字段发生变化时,在其对应的bit位设置为1,即代表此位置的字段发生⌈变化⌋,收集差异时通过遍历该Bitmask的每一位即可得知改字段是否发生过变化,当差异数据收集完成后重置Bitmask,开启下一轮标记。

注意:子孙节点发生变化需要通知父级节点标记,直到抵达根节点

原始类型

原始类型这里主要是指 整数,浮点,布尔,字符等,即在编程语言中最小不可再分割单元。

复合类型

复合类型是由一个或者多个原始数据类型组合而成。

复合类型在收集时如果遇到嵌套复合类型或者容器类型也需要深度遍历其子节点的脏数据变更,直到收集到所有变更。

容器类型

容器类型是用来存储一个或者多个由原始类型或者复合类型的容器,这里仅指代数组和字典类型,其中字典类型约定仅允许使用原始数据类型作为Key使用。

容器类型收集同复合类型规则,但是因为容器支持删除,和复用,因此需要额外的记录是否产生过删除-复用,如果产生则还需要插入一条删除操作,确保不会产生新旧数据合并导致的问题。

数据恢复

程序在运行中可能会由于各种情况导致停止运行,比如程序产生未捕获的异常,OOM,甚至是断电等,此时如果内存中存在尚未存储到存储设施中的数据就会产生丢失,根据配置存储落地时长,可能多达10min之久的数据丢失 为此在数据存储之前必须有一个机制来挽救这种情况,我们提供了一个简单的实现,binlog (二进制日志)方案,他会记录所有的变更和存档事件,当需要数据恢复时通过binlog记录的文件就可以快速恢复之前丢失的数据。

假设现在有一行数据 uid=1000 在最后一次发生存档 t7 后此时数据库中的版本为 p7 ,此后又进行了一些变更 t8-t18(加粗部分),但是他们尚未存储到数据库 。 如下图:

time t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 t11 t12 t13 t14 t15 t16 t17 t18
patch p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11 p12 p1 p2 p3 p4 p1 p2
save db save db delete delete

如果服务器此刻发生宕机,内存中尚未存储的t8-t18会丢失,此时就需要进行数据恢复,首先进行binlog扫描,排除所有已经落地的指令(最后一次落地之前都算已经完成),最后剩下的就是需要恢复的数据。 扫描完成后会发现 t8-t18 数据没有存储到数据库,从数据库中加载数据行 uid=1000,并校验版本号,开始合并数据,最终数据可以恢复到宕机之前的状态。

如果数据行发生删除后又被复用回来,因版本号将会被重置,因此校验版本号时也需要同样处理

在binlog文件时有多种写入模式,不同的工作模式会拥有不同的写盘写入性能和可靠性,应该依据实际需求进行取舍

模式 说明 写入性能 可靠性 备注
System 由操作系统决定刷写时机 断电时可能会丢未刷盘数据
Sync 同步刷写磁盘 严格保证数据完整写入
EverySecond 每秒刷写磁盘 较高 较高 每秒刷一次数据到磁盘

在紧急断电的情况下,硬盘无法完全保证会将数据刷写到磁盘,因此在默认模式下无法完全做到不丢数据, 如果开启同步刷写模式则可以保证不丢数据,但是会极大的降低系统写入性能,推荐采用EverySecond刷写模式,在性能和可靠性之间进行平衡

注意如果是机器断电则文件完整性可能无法保证,需要进行截断处理,丢弃文件尾部不完整的数据块

结束


本文旨在设计一款专用于游戏服务器使用的数据库代理服务器软件,在实际编写时需要考虑更多的的细节