Akka分布式游戏后端开发1 基础框架设计

82 阅读6分钟

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

目标架构

我们设计的集群架构大致如下,后续可以根据需要扩充节点。

  • Gm 游戏集群管理节点,包含运营功能以及运维功能,可以对玩家执行指定的操作、对任意的节点或者 Actor 执行脚本以及管理游戏集群的状态,比如服务器的开放状态以及节点的加入或者离开等等
  • Global 单例服务节点,承载在集群中的所有单例服务,通常配置两到三个,单例服务不会太多,例如某些玩法需要全服匹配而不是单服匹配,就需要这种单例服务来进行。
  • World 区服节点,承载所有的区服,基于分片机制实现,每个区服具体在哪个节点上实例化是根据分片算法计算出来的,这部分的节点数量会根据区服的数量不同会有所不同
  • Player 玩家节点,承载所有的玩家数据,也是基于分片机制实现,方便进行扩容和缩容,当玩家登录后,玩家实体会在 Player 节点进行实例化
  • Gate 网关节点,玩家登录后只会和网关节点进行交互,后续所有的消息都由网关进行转发,玩家不可直接访问其余的节点

akka架构设计.drawio.png

架构搭建

配置集群中的种子节点

根据 Akka Cluster 的文档,使用集群,在配置中写入如下代码:

akka {
  actor {
    provider = "cluster"
  }
  remote.artery {
    canonical {
      hostname = "127.0.0.1"
      port = 2551
    }
  }

  cluster {
    seed-nodes = [
      "akka://ClusterSystem@127.0.0.1:2551",
      "akka://ClusterSystem@127.0.0.1:2552"]
    
    downing-provider-class = "akka.cluster.sbr.SplitBrainResolverProvider"
  }
}

其中主要修改的地方为此节点的地址和集群中种子节点的地址

种子节点的作用

  1. 集群初始化:

    • 当新的节点想要加入 Akka 集群时,它会尝试与配置中指定的种子节点通信,以获得当前集群的成员列表。
    • 如果种子节点本身尚未加入集群,它会尝试与其他种子节点通信以初始化集群。
  2. 简化发现流程:

    • 种子节点的存在避免了所有节点直接进行点对点通信的复杂性。只需要通过种子节点,新的节点就能快速找到集群的入口。
  3. 容错性:

    • 配置多个种子节点能够提高集群的健壮性。如果某个种子节点不可用,其他种子节点仍然可以承担引导功能。

在 Akka 集群(Akka Cluster)中,种子节点(Seed Nodes) 是用于引导集群成员发现并加入集群的关键角色。以下是种子节点的作用及如何选取的详细说明:

种子节点的作用

  1. 集群初始化:

    • 当新的节点想要加入 Akka 集群时,它会尝试与配置中指定的种子节点通信,以获得当前集群的成员列表。
    • 如果种子节点本身尚未加入集群,它会尝试与其他种子节点通信以初始化集群。
  2. 简化发现流程:

    • 种子节点的存在避免了所有节点直接进行点对点通信的复杂性。只需要通过种子节点,新的节点就能快速找到集群的入口。
  3. 容错性:

    • 配置多个种子节点能够提高集群的健壮性。如果某个种子节点不可用,其他种子节点仍然可以承担引导功能。

如何选取种子节点

  1. 选择稳定可靠的节点:

    • 种子节点应尽量选择那些运行环境稳定、网络连接良好且不容易宕机的机器。
  2. 避免单点故障:

    • 应配置多个种子节点(建议至少 2-3 个),以提高种子节点的可用性,避免单点故障。
  3. 种子节点无需过多:

    • 种子节点数量不宜过多,一般保持在 2 到 5 个之间即可。过多的种子节点会增加管理和配置的复杂性。
  4. 使用固定的地址:

    • 种子节点的地址(包括 IP 或主机名和端口号)应该是固定的,不能动态变化。这是因为新节点需要通过这些固定地址找到种子节点。
  5. 部署策略:

    • 如果集群跨多个数据中心或网络区域,尽量选择不同区域内的机器作为种子节点,以提高跨区域的容错能力。

基于以上原则,我们可以选择 Gm,Global,Gate中的几个作为种子节点,当然,我们不会使用硬编码的方式去配置这些数据。我们会通过配置中心去配置这些数据,每个节点在启动的时候,从配置中心拉取这些数据,然后在和一些本地的配置做配置合并之后,作为节点启动的最终配置。

节点设计

由于每个节点都会以此方式启动,我们设计一个抽象类,在类中放入启动必要的代码以及游戏中所有节点都可能用到的一些数据,来简化新加节点可能做的额外操作。注意这不是最终的代码,只是为了写文章方便,做了很多删减。

/**
 * @author mikai233
 * @email dreamfever2017@yahoo.com
 * @date 2023/5/9
 * @param addr 节点地址
 * @param roles 节点角色
 * @param name 节点名称
 * @param config 节点配置
 * @param zookeeperConnectString zookeeper连接字符串
 */
open class Node(
    val addr: InetSocketAddress,
    val roles: List<Role>,
    val name: String,
    val config: Config,
    zookeeperConnectString: String,
    private val sameJvm: Boolean = false,
) {
    val logger = logger()

    lateinit var system: ActorSystem
        protected set

    lateinit var coroutineScope: CoroutineScope

    val zookeeper: AsyncCuratorFramework by lazy {
        val client = CuratorFrameworkFactory.newClient(
            zookeeperConnectString,
            ExponentialBackoffRetry(2000, 10, 60000)
        )
        client.start()
        AsyncCuratorFramework.wrap(client)
    }

    @Volatile
    var state: State = State.Unstarted
        private set

    protected open suspend fun changeState(newState: State) {
        val previousState = state
        state = newState
        logger.info("{} state change from:{} to:{}", this::class.simpleName, previousState, newState)
    }

    protected open suspend fun start() {
        beforeStart()
        startSystem()
        afterStart()
    }

    protected open suspend fun beforeStart() {}

    protected open suspend fun startSystem() {
        val remoteConfig = resolveRemoteConfig()
        val config = remoteConfig.withFallback(config)
        system = ActorSystem.create(name, config)
        coroutineScope = CoroutineScope(system.dispatcher.asCoroutineDispatcher() + SupervisorJob())
        changeState(State.Starting)
    }

    protected open suspend fun afterStart() {
        changeState(State.Started)
    }
}

以上抽象节点的设计,提供了基础的启动钩子以及 ActorSystem 状态变化的钩子,可供子类在必要的时候选用。

配置拉取

以下代码会从 Zookeeper 中拉取所有节点的数据,然后把种子节点过滤出来格式化成配置需要的格式, Zookeeper 中的数据大致长这样:

image.png

image.png

private fun formatSeedNode(systemName: String, host: String, port: Int) = "akka://$systemName@$host:$port"

/**
 * 获取zookeeper中整个集群的种子节点配置
 */
protected open suspend fun resolveRemoteConfig(): Config {
    val nodeConfigs = coroutineScope {
        val nodePaths = zookeeper.children.forPath(SERVER_HOSTS).await().map { host ->
            val hostPath = serverHostsPath(host)
            async {
                val nodeNames = zookeeper.children.forPath(hostPath).await()
                nodeNames.map { host to nodePath(host, it) }
            }
        }.awaitAll().flatten()
        nodePaths.map { (host, path) ->
            async {
                val data = zookeeper.data.forPath(path).await()
                host to Json.fromBytes<NodeConfig>(data)
            }
        }.awaitAll()
    }
    val seedNodeConfigs = nodeConfigs.filter { (_, config) -> config.seed }
    val seedNodes = seedNodeConfigs.map { (host, config) -> formatSeedNode(name, host, config.port) }

    val configs = mutableMapOf(
        "akka.cluster.roles" to roles.map { it.name },
        "akka.remote.artery.canonical.hostname" to addr.hostString,
        "akka.remote.artery.canonical.port" to addr.port,
        "akka.cluster.seed-nodes" to seedNodes,
        "akka.cluster.auto-down-unreachable-after" to "off",
    )
    if (sameJvm) {
        configs["akka.cluster.jmx.multi-mbeans-in-same-jvm"] = "on"
    }
    return ConfigFactory.parseMap(configs)
}

结尾

通过以上的代码配置,我们就完成了简单的游戏集群搭建。